Problem Statement
The platform is single-user-per-wallet today. A parent wanting to manage a child's savings, or a small team wanting a shared treasury, has no way to do that without literally sharing one account's full credentials — meaning full withdraw/strategy-change power, with no audit trail distinguishing who actually took an action. This issue adds sub-accounts with scoped, revocable permissions.
Current State
Auth is built around a single requireAuth middleware (src/middleware/authenticate.ts) that resolves a JWT down to a Session row (the JWT itself only carries { id: userId }; the session row, keyed by the raw token, carries userId/walletAddress/network) and sets req.userId/req.auth. enforceUserAccess then checks that the authenticated user matches the :userId route param. There is currently no concept of "acting on behalf of another user" anywhere in the middleware chain — this issue introduces one.
Proposed Solution
Data Model
model SubAccount {
id String @id @default(uuid())
parentUserId String
childUserId String
permissions SubAccountPermission[]
status SubAccountStatus @default(ACTIVE)
createdAt DateTime @default(now())
revokedAt DateTime?
parent User @relation("ParentOf", fields: [parentUserId], references: [id], onDelete: Cascade)
child User @relation("ChildOf", fields: [childUserId], references: [id], onDelete: Cascade)
@@unique([parentUserId, childUserId])
}
enum SubAccountPermission { VIEW DEPOSIT WITHDRAW MANAGE_STRATEGY }
enum SubAccountStatus { ACTIVE REVOKED }
API Surface
POST /api/admin/sub-accounts (or under /api/portfolio, TBD during implementation) — parent creates/invites a sub-account relationship with an initial permission set. requireAuth.
PATCH /api/sub-accounts/:id/permissions — parent adjusts permissions; ownership check against subAccount.parentUserId.
DELETE /api/sub-accounts/:id — revoke, setting status = REVOKED and revokedAt, not a hard delete (preserve the audit trail of what was once granted).
Integration Points
- Extend
src/middleware/authenticate.ts (or add a new middleware layered after it) to resolve an "acting as" context: when a request includes a target user id that differs from req.auth.userId, look up an ACTIVE SubAccount row linking them and check the required permission for that route before proceeding — otherwise reject. This should be a new, explicit middleware (e.g. requireSubAccountPermission(permission)) rather than quietly overloading enforceUserAccess, so the existing self-access path is untouched and this is purely additive.
- Every delegated action (a parent depositing/withdrawing/changing strategy on a child's behalf) must be attributed distinctly wherever actions are already logged —
Transaction and AgentLog — so audit trails can distinguish "the child did this themselves" from "the parent did this on the child's behalf." This likely means adding an actingAsUserId-style field alongside the existing userId on those records, not overloading userId itself.
- Revocation must take effect immediately: since
Session/JWT resolution already happens on every request (there's no long-lived cached permission set), revoking a SubAccount permission should be effective on the very next request with no separate cache-invalidation step needed — but this must be verified with a test, not assumed.
Edge Cases & Failure Modes
- Parent revokes
WITHDRAW permission while a withdrawal initiated under the old permission is already in flight on-chain — the in-flight one should complete or fail on its own merits; only future actions are blocked.
- Circular or chained sub-account relationships (a sub-account itself granting sub-accounts) — explicitly disallowed for v1; enforce at creation time.
- A permission is requested that was never granted (e.g.
MANAGE_STRATEGY without DEPOSIT) — permissions are independent flags, not a hierarchy; test that having one doesn't implicitly grant another.
- Child account also has its own independent login and can act on itself normally — sub-account permissions only affect the parent's ability to act on the child, never the child's own full self-access.
Security & Privacy Considerations
- This is the most security-sensitive item in this batch since it's explicitly building a "user A can spend user B's money" pathway — every route gaining delegated access must be enumerated explicitly (deposit, withdraw, strategy-change) rather than delegated access being a blanket bypass of all auth checks.
- Denial-path tests matter as much as happy-path tests here: a request for a permission that was never granted, or was revoked, must fail closed (403), and this needs to be tested at least as thoroughly as the success path.
AdminAuditLog (already used for admin-scope actions per src/middleware/adminAuth.ts) is a reasonable precedent to follow for how delegated actions get logged, though this is a user-to-user delegation, not an admin action — a parallel, user-facing audit trail should be visible to both parent and child, not buried in an admin-only log.
Out of Scope
- Multi-parent sub-accounts (one child, multiple parents) — single parent per sub-account for v1.
- Sub-account-initiated sub-accounts (no nesting, per Edge Cases above).
Suggested Implementation Plan
SubAccount model, migration, and parent-facing CRUD with explicit permission grants.
requireSubAccountPermission(permission) middleware, layered onto deposit/withdraw/strategy routes without touching the existing self-access path.
- Delegated-action attribution on
Transaction/AgentLog.
- Revocation-takes-effect-immediately test coverage, plus the full denial-path test suite.
Acceptance Criteria
Problem Statement
The platform is single-user-per-wallet today. A parent wanting to manage a child's savings, or a small team wanting a shared treasury, has no way to do that without literally sharing one account's full credentials — meaning full withdraw/strategy-change power, with no audit trail distinguishing who actually took an action. This issue adds sub-accounts with scoped, revocable permissions.
Current State
Auth is built around a single
requireAuthmiddleware (src/middleware/authenticate.ts) that resolves a JWT down to aSessionrow (the JWT itself only carries{ id: userId }; the session row, keyed by the raw token, carriesuserId/walletAddress/network) and setsreq.userId/req.auth.enforceUserAccessthen checks that the authenticated user matches the:userIdroute param. There is currently no concept of "acting on behalf of another user" anywhere in the middleware chain — this issue introduces one.Proposed Solution
Data Model
API Surface
POST /api/admin/sub-accounts(or under/api/portfolio, TBD during implementation) — parent creates/invites a sub-account relationship with an initial permission set.requireAuth.PATCH /api/sub-accounts/:id/permissions— parent adjusts permissions; ownership check againstsubAccount.parentUserId.DELETE /api/sub-accounts/:id— revoke, settingstatus = REVOKEDandrevokedAt, not a hard delete (preserve the audit trail of what was once granted).Integration Points
src/middleware/authenticate.ts(or add a new middleware layered after it) to resolve an "acting as" context: when a request includes a target user id that differs fromreq.auth.userId, look up anACTIVESubAccountrow linking them and check the required permission for that route before proceeding — otherwise reject. This should be a new, explicit middleware (e.g.requireSubAccountPermission(permission)) rather than quietly overloadingenforceUserAccess, so the existing self-access path is untouched and this is purely additive.TransactionandAgentLog— so audit trails can distinguish "the child did this themselves" from "the parent did this on the child's behalf." This likely means adding anactingAsUserId-style field alongside the existinguserIdon those records, not overloadinguserIditself.Session/JWT resolution already happens on every request (there's no long-lived cached permission set), revoking aSubAccountpermission should be effective on the very next request with no separate cache-invalidation step needed — but this must be verified with a test, not assumed.Edge Cases & Failure Modes
WITHDRAWpermission while a withdrawal initiated under the old permission is already in flight on-chain — the in-flight one should complete or fail on its own merits; only future actions are blocked.MANAGE_STRATEGYwithoutDEPOSIT) — permissions are independent flags, not a hierarchy; test that having one doesn't implicitly grant another.Security & Privacy Considerations
AdminAuditLog(already used for admin-scope actions persrc/middleware/adminAuth.ts) is a reasonable precedent to follow for how delegated actions get logged, though this is a user-to-user delegation, not an admin action — a parallel, user-facing audit trail should be visible to both parent and child, not buried in an admin-only log.Out of Scope
Suggested Implementation Plan
SubAccountmodel, migration, and parent-facing CRUD with explicit permission grants.requireSubAccountPermission(permission)middleware, layered onto deposit/withdraw/strategy routes without touching the existing self-access path.Transaction/AgentLog.Acceptance Criteria
SubAccountmodel + migration; parent-managed CRUD with explicit per-action permission grantsTransaction/AgentLog, visible to both parent and childdocs/openapi.yamlupdated