@wopr-network/platform-core 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/auth/tenant-access.test.js +2 -0
  2. package/dist/billing/crypto/evm/__tests__/config.test.js +10 -0
  3. package/dist/billing/crypto/evm/config.js +12 -0
  4. package/dist/billing/crypto/evm/types.d.ts +1 -1
  5. package/dist/db/schema/organization-members.d.ts +34 -0
  6. package/dist/db/schema/organization-members.js +2 -0
  7. package/dist/tenancy/org-member-repository.d.ts +12 -0
  8. package/dist/tenancy/org-member-repository.js +14 -0
  9. package/dist/tenancy/org-service.d.ts +10 -1
  10. package/dist/tenancy/org-service.js +39 -0
  11. package/dist/tenancy/org-service.test.js +219 -0
  12. package/dist/trpc/index.d.ts +1 -1
  13. package/dist/trpc/index.js +1 -1
  14. package/dist/trpc/init.d.ts +7 -0
  15. package/dist/trpc/init.js +16 -1
  16. package/dist/trpc/init.test.js +39 -1
  17. package/docs/superpowers/specs/2026-03-14-fleet-auto-update-design.md +300 -0
  18. package/docs/superpowers/specs/2026-03-14-paperclip-org-integration-design.md +359 -0
  19. package/docs/superpowers/specs/2026-03-14-role-permissions-design.md +346 -0
  20. package/drizzle/migrations/0006_invite_acceptance.sql +2 -0
  21. package/drizzle/migrations/meta/_journal.json +7 -0
  22. package/package.json +1 -1
  23. package/src/auth/tenant-access.test.ts +2 -0
  24. package/src/billing/crypto/evm/__tests__/config.test.ts +12 -0
  25. package/src/billing/crypto/evm/config.ts +13 -1
  26. package/src/billing/crypto/evm/types.ts +1 -1
  27. package/src/db/schema/organization-members.ts +2 -0
  28. package/src/tenancy/org-member-repository.ts +20 -0
  29. package/src/tenancy/org-service.test.ts +260 -0
  30. package/src/tenancy/org-service.ts +42 -1
  31. package/src/trpc/index.ts +1 -0
  32. package/src/trpc/init.test.ts +48 -0
  33. package/src/trpc/init.ts +18 -1
@@ -0,0 +1,346 @@
1
+ # Role Management & Permissions
2
+
3
+ **Date:** 2026-03-14
4
+ **Status:** Draft
5
+ **Repos:** platform-core, platform-ui-core, paperclip, paperclip-platform, paperclip-platform-ui
6
+ **Depends on:** `2026-03-14-paperclip-org-integration-design.md` (ships together or in strict sequence — this spec amends the org integration spec's `addMember`/`changeRole` to include permission grant provisioning)
7
+
8
+ ## Problem
9
+
10
+ The platform has three org roles (owner/admin/member) that gate team management operations. But billing, fleet operations, update preferences, and Paperclip-internal permissions have no role gating. A member can see billing info, restart bots, create agents, and view costs — all of which should be admin-only.
11
+
12
+ The platform controls the whole stack. Role enforcement happens at the right level: platform gates platform features, Paperclip gates Paperclip features. No workarounds.
13
+
14
+ ## Design
15
+
16
+ ### Permission Model
17
+
18
+ Three fixed roles. No per-user customization. Simple, predictable, covers the product.
19
+
20
+ **Owner** — full control. One per org.
21
+ **Admin** — manages team, billing, fleet, agents. Cannot delete org or transfer ownership.
22
+ **Member** — uses the product. Issues, projects, org chart. Nothing else.
23
+
24
+ ### Permission Matrix
25
+
26
+ | Capability | Owner | Admin | Member |
27
+ |---|---|---|---|
28
+ | **Team** | | | |
29
+ | Invite members | Y | Y | N |
30
+ | Remove members | Y | Y | N |
31
+ | Change roles | Y | Y | N |
32
+ | Transfer ownership | Y | N | N |
33
+ | Delete org | Y | N | N |
34
+ | **Billing** | | | |
35
+ | View balance/usage/invoices | Y | Y | N |
36
+ | Top up credits | Y | Y | N |
37
+ | **Fleet** | | | |
38
+ | Start/stop/restart bot | Y | Y | N |
39
+ | Trigger manual update | Y | Y | N |
40
+ | Set update mode (auto/manual) | Y | Y | N |
41
+ | View logs | Y | Y | Y |
42
+ | View bot status/health | Y | Y | Y |
43
+ | View changelog | Y | Y | Y |
44
+ | **Inside Paperclip** | | | |
45
+ | Create/manage agents | Y | Y | N |
46
+ | View costs | Y | Y | N |
47
+ | Delete company data | Y | N | N |
48
+ | Create/edit issues | Y | Y | Y |
49
+ | Create/edit projects | Y | Y | Y |
50
+ | View org chart | Y | Y | Y |
51
+
52
+ ### Where Enforcement Happens
53
+
54
+ Permissions are enforced at the correct layer — no double-gating, no workarounds.
55
+
56
+ ```
57
+ Platform (tRPC routes)
58
+ ├─ Team management → already gated by OrgService (owner/admin check on line 310)
59
+ ├─ Billing routes → gate behind admin/owner role check
60
+ ├─ Fleet routes → gate start/stop/restart/update behind admin/owner
61
+ └─ Fleet read routes → allow all members (logs, status, health, changelog)
62
+
63
+ Platform UI (platform-ui-core)
64
+ ├─ Settings tabs → hide billing, fleet controls, team tabs for members
65
+ ├─ Update button → hide for members (admin/owner only)
66
+ └─ Bot controls → hide start/stop/restart for members
67
+
68
+ Paperclip (provisioned permissions)
69
+ ├─ Agent management → admin/owner get permission grants; members don't
70
+ ├─ Cost visibility → admin/owner get cost view grants; members don't
71
+ ├─ Company deletion → owner only gets delete grant
72
+ └─ Issues/projects → all roles get full issue/project permissions
73
+ ```
74
+
75
+ ### 1. Platform-Side Role Gating (tRPC)
76
+
77
+ **File:** `paperclip-platform/src/trpc/routers/fleet.ts`
78
+
79
+ Fleet mutation routes (start, stop, restart, destroy, update) need admin/owner checks.
80
+
81
+ **Prerequisite:** Today's `orgMemberProcedure` only checks membership existence — it does NOT check role or expose `member.role` to the context. A new `orgAdminProcedure` middleware is needed in platform-core:
82
+
83
+ ```typescript
84
+ // platform-core: new middleware
85
+ const orgAdminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
86
+ const member = await orgMemberRepo.findMember(ctx.tenantId, ctx.user.id);
87
+ if (!member || member.role === "member") {
88
+ throw new TRPCError({ code: "FORBIDDEN", message: "Admin or owner role required" });
89
+ }
90
+ return next({ ctx: { ...ctx, orgRole: member.role } });
91
+ });
92
+ ```
93
+
94
+ Also extend `orgMemberProcedure` to attach `ctx.orgRole` so downstream code can read the role without re-querying.
95
+
96
+ Fleet mutation routes switch from `protectedProcedure` to `orgAdminProcedure`. Fleet read routes (listInstances, getInstance, getInstanceHealth, getInstanceLogs) use `orgMemberProcedure` — accessible to all members.
97
+
98
+ **File:** `paperclip-platform/src/trpc/routers/org.ts`
99
+
100
+ Billing routes need admin/owner checks. The existing `requireAdminOrOwner` pattern at line 310 of `platform-core/src/tenancy/org-service.ts` should be extracted into a reusable helper:
101
+
102
+ ```typescript
103
+ // platform-core/src/tenancy/org-service.ts
104
+ async requireAdminOrOwner(orgId: string, userId: string): Promise<void> {
105
+ const member = await this.memberRepo.findMember(orgId, userId);
106
+ if (!member || (member.role !== "admin" && member.role !== "owner")) {
107
+ throw new TRPCError({ code: "FORBIDDEN", message: "Admin or owner role required" });
108
+ }
109
+ }
110
+
111
+ async requireOwner(orgId: string, userId: string): Promise<void> {
112
+ const org = await this.orgRepo.getOrg(orgId);
113
+ if (!org || org.ownerId !== userId) {
114
+ throw new TRPCError({ code: "FORBIDDEN", message: "Owner role required" });
115
+ }
116
+ }
117
+ ```
118
+
119
+ Apply to routes:
120
+
121
+ | Route | Gate |
122
+ |-------|------|
123
+ | `orgBillingBalance` | `requireAdminOrOwner` |
124
+ | `orgBillingInfo` | `requireAdminOrOwner` |
125
+ | `orgMemberUsage` | `requireAdminOrOwner` |
126
+ | `orgTopupCheckout` | `requireAdminOrOwner` |
127
+ | `orgSetupIntent` | `requireAdminOrOwner` |
128
+ | `inviteMember` | `requireAdminOrOwner` (already gated) |
129
+ | `removeMember` | `requireAdminOrOwner` (already gated) |
130
+ | `changeRole` | `requireAdminOrOwner` (already gated) |
131
+ | `deleteOrganization` | `requireOwner` (already gated) |
132
+ | `transferOwnership` | `requireOwner` (already gated) |
133
+
134
+ ### 2. Platform UI Role Gating
135
+
136
+ **File:** `platform-ui-core/src/app/(dashboard)/settings/`
137
+
138
+ The settings page needs to conditionally render tabs based on the user's role. The user's role is available from the org context (tRPC `getOrganization` response includes the member list with roles).
139
+
140
+ ```typescript
141
+ // Hook: useMyOrgRole()
142
+ // Returns "owner" | "admin" | "member" | null
143
+ // Derived from org.members.find(m => m.userId === currentUser.id)?.role
144
+
145
+ const role = useMyOrgRole();
146
+ const isAdminOrOwner = role === "admin" || role === "owner";
147
+ ```
148
+
149
+ **Tab visibility:**
150
+
151
+ | Tab | Visible to |
152
+ |-----|-----------|
153
+ | Team / Members | admin, owner |
154
+ | Billing | admin, owner |
155
+ | Bot Management (start/stop/restart) | admin, owner |
156
+ | Update Preferences | admin, owner |
157
+ | Bot Status / Logs | all |
158
+ | General Settings (name, etc.) | admin, owner |
159
+
160
+ Members see a simplified dashboard: bot status, logs, changelog. No controls, no billing, no team management.
161
+
162
+ **Update modal:**
163
+
164
+ The "Update Available" badge and changelog are visible to all roles. The "Update Now" button is only visible to admin/owner. Members see the changelog but can't trigger the update.
165
+
166
+ ### 3. Paperclip-Side Permission Provisioning
167
+
168
+ **File:** `paperclip/server/src/routes/provision.ts`
169
+
170
+ When the platform provisions a user into Paperclip (via `addMember`), it also sets their permissions based on their platform role. This uses Paperclip's existing `principalPermissionGrants` system.
171
+
172
+ **Permission grants by role:**
173
+
174
+ Paperclip uses colon-delimited permission keys defined in `@paperclipai/shared/constants.ts`. The existing keys are: `agents:create`, `users:invite`, `users:manage_permissions`, `tasks:assign`, `tasks:assign_scope`, `joins:approve`.
175
+
176
+ **Prerequisite:** Several permission keys needed for role-based gating do not exist yet. These must be added to `PERMISSION_KEYS` in `paperclip/packages/shared/src/constants.ts` before implementation:
177
+
178
+ | New Key | Purpose |
179
+ |---------|---------|
180
+ | `agents:update` | Edit agent config |
181
+ | `agents:delete` | Remove agents |
182
+ | `costs:view` | See budget/spending |
183
+ | `company:delete` | Delete entire company |
184
+
185
+ ```typescript
186
+ import type { PermissionKey } from "@paperclipai/shared";
187
+
188
+ type GrantInput = { permissionKey: PermissionKey; scope?: Record<string, unknown> | null };
189
+
190
+ const ROLE_PERMISSIONS: Record<string, GrantInput[]> = {
191
+ owner: [
192
+ { permissionKey: "agents:create" },
193
+ { permissionKey: "agents:update" },
194
+ { permissionKey: "agents:delete" },
195
+ { permissionKey: "costs:view" },
196
+ { permissionKey: "company:delete" },
197
+ { permissionKey: "users:invite" },
198
+ { permissionKey: "users:manage_permissions" },
199
+ { permissionKey: "tasks:assign" },
200
+ { permissionKey: "joins:approve" },
201
+ ],
202
+ admin: [
203
+ { permissionKey: "agents:create" },
204
+ { permissionKey: "agents:update" },
205
+ { permissionKey: "agents:delete" },
206
+ { permissionKey: "costs:view" },
207
+ { permissionKey: "users:invite" },
208
+ { permissionKey: "users:manage_permissions" },
209
+ { permissionKey: "tasks:assign" },
210
+ { permissionKey: "joins:approve" },
211
+ ],
212
+ member: [
213
+ { permissionKey: "tasks:assign" },
214
+ ],
215
+ };
216
+ ```
217
+
218
+ **Note:** Issues and projects have no permission gates in Paperclip today — all company members can create/edit them. The org chart is also ungated — all authenticated users see it. No new keys needed for these.
219
+
220
+ This spec **amends** the org integration spec's `addMember` and `changeRole` provision endpoints to include permission grant provisioning. Both changes ship together.
221
+
222
+ The `addMember` provision endpoint sets these grants:
223
+
224
+ ```typescript
225
+ async addMember(companyId: string, user: AdminUser, role: "owner" | "admin" | "member") {
226
+ await this.ensureUser(user);
227
+ const paperclipRole = role === "member" ? "member" : "owner";
228
+ await access.ensureMembership(companyId, "user", user.id, paperclipRole, "active");
229
+ if (paperclipRole === "owner") {
230
+ await access.promoteInstanceAdmin(user.id);
231
+ }
232
+
233
+ // Set permission grants based on platform role
234
+ const grants = ROLE_PERMISSIONS[role] ?? ROLE_PERMISSIONS.member;
235
+ await access.setPrincipalGrants(companyId, "user", user.id, grants, "platform");
236
+ }
237
+ ```
238
+
239
+ The `changeRole` endpoint updates grants when a role changes:
240
+
241
+ ```typescript
242
+ async changeRole(companyId: string, userId: string, role: "owner" | "admin" | "member") {
243
+ // ... existing role/membership logic from org integration spec ...
244
+
245
+ // Update permission grants to match new role
246
+ const grants = ROLE_PERMISSIONS[role] ?? ROLE_PERMISSIONS.member;
247
+ await access.setPrincipalGrants(companyId, "user", userId, grants, "platform");
248
+ }
249
+ ```
250
+
251
+ ### 4. Paperclip UI Enforcement (hostedMode)
252
+
253
+ Paperclip's UI already has a permission system, but some controls need hostedMode-specific hiding. In hosted mode, the platform is the authority — Paperclip should not show controls that the platform's role doesn't allow.
254
+
255
+ **Agent management controls:**
256
+ Already gated by the `agent.create` permission grant. Members without the grant won't see create/edit/delete agent buttons. No additional hostedMode guard needed — the permission system handles it.
257
+
258
+ **Cost page:**
259
+ Gate behind `costs:view` permission. Members without it see a "Contact your admin" message or the page is hidden from navigation entirely.
260
+
261
+ **Company deletion:**
262
+ Already behind instance admin check. Members are never instance admin. No change needed.
263
+
264
+ **Key principle:** In hosted mode, Paperclip's permission grants are the enforcement mechanism. The platform sets the right grants during provisioning. Paperclip's UI respects those grants. No additional hostedMode guards needed for role-based features — only for org management UI (invites, members, company settings) which is hidden entirely per the org integration spec.
265
+
266
+ ### 5. Role Change Propagation
267
+
268
+ When a role changes on the platform, the change must propagate to Paperclip:
269
+
270
+ ```
271
+ Platform: admin changes Alice from member to admin
272
+ → platform updates organization_members role
273
+ → platform calls POST instance:3100/internal/members/change-role
274
+ { companyId, userId: alice.id, role: "admin" }
275
+ → Paperclip updates membership + permission grants
276
+ → Alice's next request gets the expanded permissions
277
+ ```
278
+
279
+ No restart needed. No container recreation. Permission grants take effect immediately on the next request because `actorMiddleware` resolves memberships fresh on each request.
280
+
281
+ ## Files to Create/Modify
282
+
283
+ ### platform-core
284
+
285
+ | File | Action | Description |
286
+ |------|--------|-------------|
287
+ | `src/tenancy/org-service.ts` | Modify | Extract `requireAdminOrOwner()` and `requireOwner()` helpers |
288
+ | `src/tenancy/org-member-procedure.ts` | Create | `orgAdminProcedure` middleware that checks admin/owner role; extend `orgMemberProcedure` to attach `ctx.orgRole` |
289
+
290
+ ### paperclip-platform
291
+
292
+ | File | Action | Description |
293
+ |------|--------|-------------|
294
+ | `src/trpc/routers/fleet.ts` | Modify | Add admin/owner role checks to mutation routes |
295
+ | `src/trpc/routers/org.ts` | Modify | Add admin/owner role checks to billing routes |
296
+
297
+ ### platform-ui-core
298
+
299
+ | File | Action | Description |
300
+ |------|--------|-------------|
301
+ | `src/hooks/useMyOrgRole.ts` | Create | Hook to get current user's org role |
302
+ | `src/app/(dashboard)/settings/` | Modify | Conditionally render tabs based on role |
303
+ | Bot management components | Modify | Hide start/stop/restart controls for members |
304
+ | Update modal | Modify | Hide "Update Now" button for members |
305
+
306
+ ### paperclip
307
+
308
+ | File | Action | Description |
309
+ |------|--------|-------------|
310
+ | `server/src/routes/provision.ts` | Modify | Set permission grants based on platform role in addMember/changeRole |
311
+ | `packages/shared/src/constants.ts` | Modify | Add new PERMISSION_KEYS: `agents:update`, `agents:delete`, `costs:view`, `company:delete` |
312
+
313
+ ### paperclip-platform-ui
314
+
315
+ | File | Action | Description |
316
+ |------|--------|-------------|
317
+ | Settings layout | Modify | Hide billing/team/fleet tabs for members |
318
+ | Bot card | Modify | Hide controls for members, keep status/logs visible |
319
+
320
+ ## Edge Cases
321
+
322
+ | Scenario | Behavior |
323
+ |----------|----------|
324
+ | Owner demoted to member | Not possible — owner must transfer ownership first |
325
+ | Last admin removed | OrgService already prevents this (countAdminsAndOwners check) |
326
+ | Admin changes own role to member | Allowed — they lose admin access immediately |
327
+ | User in multiple orgs with different roles | Roles are per-org. Switching org changes visible permissions |
328
+ | Provision call fails during role change | Platform role is updated. Paperclip permissions are stale until retry succeeds. User may have expanded/restricted access temporarily. Reconciliation job (from org spec) catches drift. |
329
+ | New permission added in future | Add to ROLE_PERMISSIONS map. Existing users get it on next role change or via reconciliation. |
330
+
331
+ ## Risks
332
+
333
+ | Risk | Mitigation |
334
+ |------|------------|
335
+ | Platform and Paperclip permissions drift | Provision calls are synchronous with role changes. Reconciliation job as safety net. |
336
+ | Upstream Paperclip adds new permission-gated features | Members won't have grants for new permissions by default — safe fail-closed. Admins get grants added via ROLE_PERMISSIONS update. |
337
+ | Role check latency on every tRPC call | org member lookup is a single indexed query. Cache role in session if needed. |
338
+ | Member sees flash of admin UI before role loads | `useMyOrgRole()` returns null while loading. Show skeleton/nothing until role resolves. |
339
+
340
+ ## Future Considerations
341
+
342
+ - **Viewer role:** If a read-only role is needed (common enterprise request), add `"viewer"` to the role enum with an empty `ROLE_PERMISSIONS` entry. Viewers would see status/logs/changelog but cannot create issues or projects. The three-role model is extensible to four without architectural changes.
343
+ - **Custom permission sets:** If customers need "billing admin" or "agent manager" sub-roles, extend ROLE_PERMISSIONS with custom role definitions. The infrastructure supports it — just add roles.
344
+ - **Org-level feature flags:** Some features could be gated per org tier (enterprise gets more). Orthogonal to roles but shares the same gating infrastructure.
345
+ - **Audit log:** Log all role changes and permission grant modifications for compliance.
346
+ - **Reconciliation job:** Periodic job (e.g., hourly cron) that compares platform org members + roles against Paperclip company members + permission grants, and corrects any drift from failed provision calls. Defined in the org integration spec as future work — should be implemented alongside or shortly after initial ship.
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "organization_invites" ADD COLUMN "accepted_at" bigint;--> statement-breakpoint
2
+ ALTER TABLE "organization_invites" ADD COLUMN "revoked_at" bigint;
@@ -43,6 +43,13 @@
43
43
  "when": 1742054400000,
44
44
  "tag": "0005_stablecoin_columns",
45
45
  "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "7",
50
+ "when": 1742140800000,
51
+ "tag": "0006_invite_acceptance",
52
+ "breakpoints": true
46
53
  }
47
54
  ]
48
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -17,6 +17,8 @@ function mockOrgMemberRepo(overrides: Partial<IOrgMemberRepository> = {}): IOrgM
17
17
  deleteInvite: vi.fn().mockResolvedValue(undefined),
18
18
  deleteAllMembers: vi.fn().mockResolvedValue(undefined),
19
19
  deleteAllInvites: vi.fn().mockResolvedValue(undefined),
20
+ listOrgsByUser: vi.fn().mockResolvedValue([]),
21
+ markInviteAccepted: vi.fn().mockResolvedValue(undefined),
20
22
  ...overrides,
21
23
  };
22
24
  }
@@ -23,6 +23,18 @@ describe("getTokenConfig", () => {
23
23
  expect(cfg.contractAddress).toBe("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913");
24
24
  });
25
25
 
26
+ it("returns USDT on Base", () => {
27
+ const cfg = getTokenConfig("USDT", "base");
28
+ expect(cfg.decimals).toBe(6);
29
+ expect(cfg.contractAddress).toBe("0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2");
30
+ });
31
+
32
+ it("returns DAI on Base", () => {
33
+ const cfg = getTokenConfig("DAI", "base");
34
+ expect(cfg.decimals).toBe(18);
35
+ expect(cfg.contractAddress).toBe("0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb");
36
+ });
37
+
26
38
  it("throws on unsupported token/chain combo", () => {
27
39
  // biome-ignore lint/suspicious/noExplicitAny: testing invalid input
28
40
  expect(() => getTokenConfig("USDC" as any, "ethereum" as any)).toThrow("Unsupported token");
@@ -10,13 +10,25 @@ const CHAINS: Record<EvmChain, ChainConfig> = {
10
10
  },
11
11
  };
12
12
 
13
- const TOKENS: Record<`${StablecoinToken}:${EvmChain}`, TokenConfig> = {
13
+ const TOKENS: Partial<Record<`${StablecoinToken}:${EvmChain}`, TokenConfig>> = {
14
14
  "USDC:base": {
15
15
  token: "USDC",
16
16
  chain: "base",
17
17
  contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
18
18
  decimals: 6,
19
19
  },
20
+ "USDT:base": {
21
+ token: "USDT",
22
+ chain: "base",
23
+ contractAddress: "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2",
24
+ decimals: 6,
25
+ },
26
+ "DAI:base": {
27
+ token: "DAI",
28
+ chain: "base",
29
+ contractAddress: "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb",
30
+ decimals: 18,
31
+ },
20
32
  };
21
33
 
22
34
  export function getChainConfig(chain: EvmChain): ChainConfig {
@@ -2,7 +2,7 @@
2
2
  export type EvmChain = "base";
3
3
 
4
4
  /** Supported stablecoin tokens. */
5
- export type StablecoinToken = "USDC";
5
+ export type StablecoinToken = "USDC" | "USDT" | "DAI";
6
6
 
7
7
  /** Chain configuration. */
8
8
  export interface ChainConfig {
@@ -32,6 +32,8 @@ export const organizationInvites = pgTable(
32
32
  createdAt: bigint("created_at", { mode: "number" })
33
33
  .notNull()
34
34
  .default(sql`(extract(epoch from now()) * 1000)::bigint`),
35
+ acceptedAt: bigint("accepted_at", { mode: "number" }),
36
+ revokedAt: bigint("revoked_at", { mode: "number" }),
35
37
  },
36
38
  (table) => [index("idx_org_invites_org_id").on(table.orgId), index("idx_org_invites_token").on(table.token)],
37
39
  );
@@ -31,6 +31,8 @@ export interface OrgInviteRow {
31
31
  token: string;
32
32
  expiresAt: number;
33
33
  createdAt: number;
34
+ acceptedAt: number | null;
35
+ revokedAt: number | null;
34
36
  }
35
37
 
36
38
  // ---------------------------------------------------------------------------
@@ -52,6 +54,8 @@ export interface IOrgMemberRepository {
52
54
  deleteInvite(inviteId: string): Promise<void>;
53
55
  deleteAllMembers(orgId: string): Promise<void>;
54
56
  deleteAllInvites(orgId: string): Promise<void>;
57
+ listOrgsByUser(userId: string): Promise<Array<{ orgId: string; role: string }>>;
58
+ markInviteAccepted(inviteId: string): Promise<void>;
55
59
  }
56
60
 
57
61
  // ---------------------------------------------------------------------------
@@ -78,6 +82,8 @@ function toInvite(row: typeof organizationInvites.$inferSelect): OrgInviteRow {
78
82
  token: row.token,
79
83
  expiresAt: row.expiresAt,
80
84
  createdAt: row.createdAt,
85
+ acceptedAt: row.acceptedAt ?? null,
86
+ revokedAt: row.revokedAt ?? null,
81
87
  };
82
88
  }
83
89
 
@@ -156,4 +162,18 @@ export class DrizzleOrgMemberRepository implements IOrgMemberRepository {
156
162
  async deleteAllInvites(orgId: string): Promise<void> {
157
163
  await this.db.delete(organizationInvites).where(eq(organizationInvites.orgId, orgId));
158
164
  }
165
+
166
+ async listOrgsByUser(userId: string): Promise<Array<{ orgId: string; role: string }>> {
167
+ return this.db
168
+ .select({ orgId: organizationMembers.orgId, role: organizationMembers.role })
169
+ .from(organizationMembers)
170
+ .where(eq(organizationMembers.userId, userId));
171
+ }
172
+
173
+ async markInviteAccepted(inviteId: string): Promise<void> {
174
+ await this.db
175
+ .update(organizationInvites)
176
+ .set({ acceptedAt: Date.now() })
177
+ .where(eq(organizationInvites.id, inviteId));
178
+ }
159
179
  }