Skip to content

Commit 00a8e83

Browse files
feat: /transfer-data endpoint
1 parent f8170ef commit 00a8e83

22 files changed

Lines changed: 1067 additions & 61 deletions

‎Dockerfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ COPY src/extensions/endpoints/metadata/package.json src/extensions/endpoints/met
1818
COPY src/extensions/endpoints/redirect/package.json src/extensions/endpoints/redirect/
1919
COPY src/extensions/endpoints/sponsorship-details/package.json src/extensions/endpoints/sponsorship-details/
2020
COPY src/extensions/endpoints/sync-github-data/package.json src/extensions/endpoints/sync-github-data/
21+
COPY src/extensions/endpoints/transfer-data/package.json src/extensions/endpoints/transfer-data/
2122
COPY src/extensions/hooks/adopted-probe/package.json src/extensions/hooks/adopted-probe/
2223
COPY src/extensions/hooks/cors/package.json src/extensions/hooks/cors/
2324
COPY src/extensions/hooks/directus-users/package.json src/extensions/hooks/directus-users/

‎features/orgs/account-migration.md‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
# Account migration
22

3-
Moving a user's own account into an org - the "migrate to organization" button. The back end is one endpoint and one
4-
transaction; the caller says which entities to move (probes, tokens, credits), so a user can take one part over without the
5-
rest. The UI is phase 4.
3+
Moving a user's own account into an org - the "migrate to organization" button. The back end is one endpoint per entity kind
4+
(probes, tokens, credits), each its own transaction, so a user can take one part over without the rest. The UI is phase 4.
65

76
**Ships after phase 2, not with phase 1.** Before the readers move to `account_id` the transfer is either a no-op or harmful:
87
the balance leaves `gp_credits.user_id`, where `credits-master` still looks for it (`credits-master.ts:65,98,117`), so the
@@ -29,7 +28,7 @@ and whoever claims an org can demote the admins who arrive later, until the sync
2928

3029
## 2. Membership
3130

32-
The migrating user becomes an admin of the org regardless of their GitHub role, creating the membership if it is missing. Safe
31+
The migrating user becomes an admin of the org regardless of their GitHub role. Safe
3332
against the sync, which only ever promotes: a manually assigned admin is never demoted.
3433

3534
## 3. Probes
@@ -95,8 +94,8 @@ one direction would show them to nobody. The only direction that can be created
9594
sponsorship at an org they belong to; the org -> user rows stay as they are and are expected to disappear as their owners transfer.
9695
Then:
9796

98-
- if a redirect org -> user exists and the user migrates their credits anywhere, that redirect row is deleted;
99-
- a user -> org redirect is created. That is the only kind of redirect that can be created from now on;
97+
- no redirect is created: the credits move once, while a redirect routes what arrives later, so pointing a sponsorship at an org
98+
is its own endpoint (`phase3.md` 3.1) and its own decision;
10099
- the history moves: `gp_credits_additions.github_id` becomes the org's, which carries the sponsor bonus with it - `getUserBonus`
101100
sums the last 12 months by `github_id`, so nothing has to know about redirects;
102101
- the balance and the deductions move to the org account. Two traps: `gp_credits` is `UNIQUE(account_id)` and

‎features/orgs/phase3.md‎

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,18 @@ the removal UI of phase 4 reachable, and 2.1 is what makes it safe.
123123
- `transfer probes`, `transfer tokens`, `transfer credits` - one per entity kind, each its own transaction. A user may hand over
124124
their probes and keep their credits; nothing forces the three to happen together or in any order.
125125
- `credits-redirect` GET - the redirects where the active account's github id is the source or the target. It is the only
126-
reader of the table (1.4), so it is also what keeps a viewer from seeing them. Each side comes back with its name - the user's
126+
reader of the table (1.4), and the org half of it is admin-only for the reason 1.4 gives: the counterparty of an org's redirect
127+
is a person no other row a member can read names. Each side comes back with its name - the user's
127128
`github_username` or the org's `name`, the bare id when neither table knows it - because the dash shows `john => acme-org` and
128129
can read no other user's row.
129-
- `credits-redirect` POST - manages the redirect whose **source is the active account's github id**, which is the one that gives
130-
its money away. Acting personally, a user points their sponsorship at an org they belong to, replaces that choice, or clears it
131-
with `orgId: null`. Acting as an org, an admin can only clear: the `org -> user` rows we inherited are removable by the org that
132-
funds them, which is the one thing that lets a redirect be undone when its target is gone. Creation stays one-directional -
133-
user -> org, towards an org the caller belongs to. Pointing at an org that is itself redirected is allowed and needs no thought:
134-
the lookup is one hop (1.3).
130+
- `credits-redirect` POST - a person points their sponsorship at an org they belong to. Only that direction exists: an org
131+
receives a sponsorship, it never passes one on. It also ends every row that touches the caller, in either direction - sending
132+
their own money somewhere says they are nobody's stand-in any more. Pointing at an org that is itself redirected is allowed and
133+
needs no thought: the lookup is one hop (1.3).
134+
- `credits-redirect` DELETE - removes one named row, `{ source, target }`, and either of the two sides may do it: the one giving
135+
its sponsorship away, and the one that has been receiving it. Naming the row is what lets an org end a single member's donation
136+
rather than all of them, and it is what makes the inherited `org -> user` rows removable - by the org that funds them, and by
137+
the person they route through.
135138

136139
The org is named by id; the caller is the session user, acting as the active account.
137140

@@ -143,11 +146,11 @@ The org is named by id; the caller is the session user, acting as the active acc
143146
| transfer tokens | always | always | never |
144147
| transfer credits | always | always | never |
145148
| `credits-redirect` POST, own sponsorship | always | always | never |
146-
| `credits-redirect` POST, clearing the org's own redirect | always | never | never |
149+
| `credits-redirect` DELETE, a row this account is a side of | always | always for their own account, never for the org's | never |
147150

148-
Plus one branch outside the matrix: the target of an `org -> user` redirect from that org may transfer even once the org has an
149-
admin - that redirect is our evidence that this particular person is the org. The branch has to check that the redirect points at
150-
the caller; "a redirect exists for the org" would let every member of such an org walk in.
151+
The matrix is the whole rule; probes have no exception. A redirect is about where money goes, not about who may act for an org,
152+
and reading one as permission would tie a probe capability to a row either side can delete. Somebody who has to hand probes over
153+
to an org that already has an admin asks that admin instead.
151154

152155
The dash cannot pre-compute the probes row - a member may not read the org's memberships, so it cannot tell whether the org has an
153156
admin - so that rejection has to come back as its own error, distinguishable from "you are a viewer" and from "no such org", and
@@ -168,8 +171,8 @@ yields admin or member - so it is an explicit decision and it wins.
168171
DB**. Once it has one the operations stop granting anything, which is what closes the door behind the first claimant. Safe against
169172
the members cron, which only ever promotes - a manually assigned admin is never demoted.
170173

171-
The check and the promotion belong in the same transaction, with the org row locked: two members reading "no admin" at once would
172-
otherwise both become one, and the door would not close behind either.
174+
Two members transferring at the same moment both read "no admin" and both end up admins. Accepted: each of them was entitled to
175+
claim the org at the moment they asked, so the outcome is one the rules already allow, just reached twice.
173176

174177
Known and accepted: an org that has not approved the `globalping` OAuth app is invisible to the memberships endpoint, so everybody
175178
including its owners arrives through the public list with no role and is stored as a member (`github-api-client.ts:66`). Such an
@@ -198,8 +201,11 @@ migration never overwrites the first.
198201
with it. The approval is not access - the token is - but it has to move for two reasons: `/applications/revoke` deletes from both
199202
tables by the same `{ account_id, user_created }`, and an approval whose tokens left is invisible in the applications list while
200203
its consent silently keeps skipping the consent screen. The collision is the thing to get right: the personal row becomes
201-
`(user, app, org account)`, which the org may already have. Merge them, taking the union of the scopes, the way gp-auth itself
202-
unions them (`model.ts:198`). `user_created` never changes - moving the row changes which account is billed, not who consented.
204+
`(user, app, org account)`, which the org may already have. Then the org's row wins and the personal one is dropped - an approval
205+
only decides whether the consent screen is shown the next time the app asks, and a transfer is nobody answering that screen, so
206+
the scopes the org consented to are not for it to widen. Nothing breaks either way: gp-auth reads an approval only in
207+
`saveAuthorizationCode`, never when a token is used. `user_created` never changes - moving the row changes which account is
208+
billed, not who consented.
203209

204210
3.7 **Credits.** The balance and the deductions move to the org account, and the additions history moves with them
205211
(`gp_credits_additions.github_id` becomes the org's), which carries the sponsor bonus along - `getUserBonus` sums the last twelve
@@ -213,8 +219,10 @@ inherits the percentage the donations earned, and the giver's timeline loses the
213219
(`credits-timeline/src/index.ts:96`). It is safe to rewrite only because of 1.5 - the cron no longer recognises an addition by the
214220
id it was filed under.
215221

216-
If an `org -> user` redirect pointed at the caller, transferring their credits deletes that row: the org now holds both the
217-
history and the future sponsorship, and the redirect has nothing left to do.
222+
Redirects are left alone. Handing over the credits that have arrived and routing the ones that will are two decisions, and the
223+
`org -> user` row is the org's, not the caller's: clearing it reroutes the org's money, which is why it has its own endpoint and
224+
its own confirmation (3.1). An org that still redirects to someone who just gave it their credits is a state the org ends, not a
225+
side effect of their button.
218226

219227
3.8 **Failure.** Each endpoint is one transaction, so a failed transfer leaves its own entity kind untouched and says nothing
220228
about the other two. The adoption token is the only part with an effect outside the database - the API's token map refreshes
@@ -253,13 +261,15 @@ and no screen to show it on.
253261

254262
## 6. Tests
255263

256-
6.1 **Unit**, per extension: the authorization matrix (3.2) with every cell and the redirect branch, the promotion window (3.3),
257-
the approval merge (3.6), the credits traps (3.7), the subset validation (2.1).
264+
6.1 **Unit**, per extension: the authorization matrix (3.2) with every cell, the promotion window (3.3), the subset validation
265+
(2.1), the names the redirect list falls back to (3.1), and the two conditions around the adoption token (3.5) - a probe has to
266+
have moved, and the entry has to carry a username. Not the credits and approval moves: those are unique keys, a trigger and what
267+
MariaDB allows in a subquery, so a unit test there asserts the implementation's own statements back at it.
258268

259269
6.2 **e2e over REST**: each transfer alone and all three in sequence; a member refused the probes transfer in an org that has an
260270
admin; the first member of an admin-less org becoming its admin; a second member transferring into the same org (the token array
261-
grows, the first entry survives); a transfer into an org that already holds an approval of the same app (scopes union); a redirect
262-
created, overwritten and deleted; a rollback on a forced failure leaving nothing moved; an org admin reading `id` and
271+
grows, the first entry survives); a transfer into an org that already holds an approval of the same app (the org's row stands); a redirect
272+
created, overwritten, refused by the side receiving it and deleted; a rollback on a forced failure leaving nothing moved; an org admin reading `id` and
263273
`github_username` of the org's members and nothing else of them, while a member and a viewer read no co-member at all.
264274

265275
## Deploy

‎features/orgs/phase4.md‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,17 @@ are independent and may be used at different times.
122122
7.3 **What each modal says.**
123123

124124
- **Probes**: the probes' global tag changes to the org's name (the old one is replaced outright, no grace period), and the user's
125-
adoption token is handed to the org and replaced with a fresh one. It is the one transfer that can be refused for a reason the
126-
screen could not have known - the org already has an admin, so only an admin may hand probes over - so the error says exactly
127-
that and names the way out: ask an admin of the org to do it.
125+
adoption token is handed to the org and replaced with a fresh one. The modal names both tags, and it says so when the org's
126+
public probes switch is off while the user's is on: the tag does not change then, it disappears, and the probes stop being
127+
globally targetable under any name until an admin turns the switch on (6.2).
128+
129+
It is also the one transfer that can be refused for a reason the screen could not have known - the org already has an admin, so
130+
only an admin may hand probes over - so the error says exactly that. Nobody can do it on their behalf, since a transfer only
131+
ever moves the caller's own probes, so the way out is being made an admin of the org.
128132
- **Tokens**: the tokens and their app approvals move, and what they spend from then on is the org's credits.
129133
- **Credits**: the balance, the deductions and the sponsorship history move, and the sponsor bonus with them. If an
130-
`org -> user` redirect points at the user, the modal names it, because the transfer deletes it - nobody should discover
131-
afterwards that their sponsorship stopped arriving where it used to.
134+
`org -> user` redirect points at the user, the modal names it and says it stays: the org keeps sending its sponsorship to them
135+
until an admin of that org clears it on the Organization screen (6.2).
132136
- **Redirect**: routing future sponsorship, not handing over anything that exists, so it is reversible and carries no warning.
133137
The row shows the current state next to the button, `john => acme-org`, with a cross that clears it after a confirmation modal.
134138
Picking an org replaces whatever the user had before.

0 commit comments

Comments
 (0)