@happyvertical/smrt-users 0.55.18 → 0.55.19

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 (39) hide show
  1. package/README.md +47 -0
  2. package/agents/oauth-authorization.md +124 -0
  3. package/agents/retention.md +18 -0
  4. package/dist/chunks/{TerminalAuthService-DjGsRtqb.js → TerminalAuthService-DWPeKl3k.js} +2 -2
  5. package/dist/chunks/TerminalAuthService-DWPeKl3k.js.map +1 -0
  6. package/dist/collections/OAuthAuthorizationCollection.d.ts +31 -0
  7. package/dist/collections/OAuthAuthorizationCollection.d.ts.map +1 -0
  8. package/dist/collections/index.d.ts +1 -0
  9. package/dist/collections/index.d.ts.map +1 -1
  10. package/dist/index.d.ts +4 -4
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +832 -33
  13. package/dist/index.js.map +1 -1
  14. package/dist/manifest.json +4590 -2529
  15. package/dist/models/OAuthAuthorization.d.ts +70 -0
  16. package/dist/models/OAuthAuthorization.d.ts.map +1 -0
  17. package/dist/models/index.d.ts +1 -0
  18. package/dist/models/index.d.ts.map +1 -1
  19. package/dist/retention.d.ts +3 -1
  20. package/dist/retention.d.ts.map +1 -1
  21. package/dist/services/OAuthAuthorizationService.d.ts +52 -0
  22. package/dist/services/OAuthAuthorizationService.d.ts.map +1 -0
  23. package/dist/services/OAuthAuthorizationStorage.d.ts +39 -0
  24. package/dist/services/OAuthAuthorizationStorage.d.ts.map +1 -0
  25. package/dist/services/index.d.ts +2 -0
  26. package/dist/services/index.d.ts.map +1 -1
  27. package/dist/services/oauth-retention.d.ts +14 -0
  28. package/dist/services/oauth-retention.d.ts.map +1 -0
  29. package/dist/services/oauth-transaction.d.ts +4 -0
  30. package/dist/services/oauth-transaction.d.ts.map +1 -0
  31. package/dist/smrt-knowledge.json +925 -48
  32. package/dist/sveltekit/index.d.ts +1 -0
  33. package/dist/sveltekit/index.d.ts.map +1 -1
  34. package/dist/sveltekit/oauth-handlers.d.ts +25 -0
  35. package/dist/sveltekit/oauth-handlers.d.ts.map +1 -0
  36. package/dist/sveltekit.js +93 -25
  37. package/dist/sveltekit.js.map +1 -1
  38. package/package.json +13 -12
  39. package/dist/chunks/TerminalAuthService-DjGsRtqb.js.map +0 -1
package/README.md CHANGED
@@ -1355,3 +1355,50 @@ against the new constraint. Apply the remaining application schema migrations,
1355
1355
  run `smrt doctor --db` / `db:status --parity`, deploy the new version to every
1356
1356
  writer, then resume traffic. Do not roll back application writers without also
1357
1357
  restoring the pre-upgrade schema/database backup.
1358
+
1359
+ ## Hosted OAuth authorization
1360
+
1361
+ Use `SmrtOAuthAuthorizationStorage` with the SDK authorization server, and
1362
+ `SmrtOAuthAuthorizationService` to bind consent to existing browser sessions:
1363
+
1364
+ ```typescript
1365
+ import { createAuthorizationServer } from '@happyvertical/auth/server';
1366
+ import {
1367
+ SmrtOAuthAuthorizationService,
1368
+ SmrtOAuthAuthorizationStorage,
1369
+ } from '@happyvertical/smrt-users';
1370
+
1371
+ const authorization = await SmrtOAuthAuthorizationService.create({
1372
+ db,
1373
+ scopePermissions: { 'catalog:read': ['catalog.read'] },
1374
+ });
1375
+ const server = createAuthorizationServer({
1376
+ issuer: 'https://app.example/oauth',
1377
+ signingKey,
1378
+ storage: await SmrtOAuthAuthorizationStorage.create({ db }),
1379
+ identity: authorization.identity,
1380
+ scopes: ['catalog:read'],
1381
+ resources: ['https://app.example/mcp'],
1382
+ });
1383
+ // After explicit same-origin browser consent:
1384
+ const parsed = await server.parseAuthorizationRequest(url.searchParams);
1385
+ const redirect = await authorization.approve(server, parsed, sessionId);
1386
+ // On every bearer request, verify cryptography AND live authorization:
1387
+ const payload = await server.verifyAccessToken(token, 'https://app.example/mcp');
1388
+ const context = await authorization.validateAccessTokenClaims(payload);
1389
+ if (!context) throw new Error('Authorization is no longer active.');
1390
+ ```
1391
+
1392
+ The SvelteKit export `createOAuthHandlers` supplies optional session-aware
1393
+ consent and account-grant routes. `listGrants(sessionId)` and
1394
+ `revokeGrant(sessionId, grantId)` confine account management to the signed-in
1395
+ subject. Migrate the private `users_oauth_*` tables before enabling the issuer.
1396
+ See [OAuth authorization](agents/oauth-authorization.md) for lifecycle,
1397
+ revocation, permission ceilings and the database test matrix.
1398
+
1399
+ For local SQLite processes sharing a file, configure the storage's optional
1400
+ `recoverDatabase` factory to recreate an invalidated connection and reapply its
1401
+ settings. Instance-only storage fails closed on an invalidated connection; it
1402
+ never silently drops PRAGMAs or temporary state. PostgreSQL does not require
1403
+ this option. The [transaction notes](agents/oauth-authorization.md#recoverable-local-sqlite-connections)
1404
+ show the factory contract.
@@ -0,0 +1,124 @@
1
+ # Hosted OAuth authorization
2
+
3
+ `SmrtOAuthAuthorizationStorage` implements the protocol-neutral storage contract
4
+ from `@happyvertical/auth/server`. Apply normal SMRT migrations before starting
5
+ the server. The six `users_oauth_*` models are sensitive and have no generated
6
+ REST, MCP or CLI operations. Keep their tables on the private application
7
+ connection; public catalog roles must not read them.
8
+
9
+ `SmrtOAuthAuthorizationService.create({ db, scopePermissions })` supplies the
10
+ session boundary. `approve(server, parsedRequest, sessionId)` loads the existing
11
+ session again, checks its active user and tenant membership, verifies every
12
+ scope's configured permissions, and records consent. Existing magic-link,
13
+ password and OIDC sessions work without a separate identity mapping. Parent
14
+ session validity and session permission ceilings are enforced by SessionService.
15
+ Unknown scopes fail closed. An empty permission mapping explicitly permits a
16
+ scope for any valid session. OAuth does not grant super-admin bypass.
17
+
18
+ Pass `service.identity` to the SDK server. It revalidates the stored consent on
19
+ both code exchange and refresh. A permission loss denies the grant rather than
20
+ silently expanding or partially authorizing it. The consent-time permission
21
+ snapshot is an upper bound even if the host later changes its scope mapping.
22
+
23
+ After `server.verifyAccessToken(token, resource)`, always call
24
+ `service.validateAccessTokenClaims(payload)`. A non-null result is a server-only
25
+ SessionContext whose permissions and permissionCeiling are limited to the
26
+ verified token's mapped scopes. Do not serialize that context: it contains the
27
+ session credential. The JWT carries only a non-secret `smrt_grant_id`, subject,
28
+ client, tenant and scopes. JWT signature verification alone does not check live
29
+ session, user, tenant or consent revocation.
30
+
31
+ `listGrants(sessionId)` returns safe account-settings projections.
32
+ `revokeGrant(sessionId, grantId)` checks ownership and atomically revokes the
33
+ consent, outstanding codes, and all refresh descendants. Revoking or replaying a
34
+ managed refresh token also revokes its consent, so existing access tokens fail
35
+ the live grant check. Repeating revocation is safe. A foreign grant returns false.
36
+
37
+ The `/sveltekit` export `createOAuthHandlers({ server, authorization,
38
+ getSessionId })` provides Fetch-compatible `authorize`, `consent`, `grants` and
39
+ `protocol` handlers. The host renders consent, supplies its existing cookie
40
+ lookup, and preserves a validated local return path through magic-link login.
41
+ Consent POST requires an exact same-origin Origin header and `decision=approve`;
42
+ requested OAuth parameters remain on the authorization URL and are revalidated
43
+ by the SDK. Grant revocation POST has the same origin check and takes `grantId`.
44
+ All account/consent responses use private, no-store. No handler authenticates a
45
+ session from OAuth client identity or accepts a browser-submitted user/tenant.
46
+
47
+ ## Transaction invariants
48
+
49
+ Code consume uses a conditional write. Refresh rotation and revocation lock a
50
+ stable family row before reading token state; locking the token being replaced
51
+ alone would allow a concurrent revocation to miss an inserted descendant.
52
+ Managed operations lock the consent row first, then the family. Replacement
53
+ insertion, old-token consume and replay revocation share one database executor.
54
+ A failed insert rolls back consume. Revalidated scopes and claims are persisted
55
+ atomically before the SDK exposes the replacement. Undefined claims clear prior
56
+ claims. Narrowing cannot expand scopes, revive or modify a consumed token.
57
+
58
+ Retry only transaction failures classified as SQLite contention or PostgreSQL
59
+ serialization/deadlock, after adapter rollback, at most six retries. Never retry
60
+ protocol callbacks or other external effects. The database adapter must clean up
61
+ failed statements and support independent connections; a process-local mutex is
62
+ not a replica-safety mechanism.
63
+
64
+ ## Behavior-to-test contract (#3659)
65
+
66
+ Every row runs in `src/__tests__/oauth-authorization.test.ts` on Node >=26 with
67
+ SQLite and real PostgreSQL. Commands:
68
+
69
+ ```sh
70
+ pnpm --filter @happyvertical/smrt-users exec vitest run src/__tests__/oauth-authorization.test.ts
71
+ DATABASE_URL=postgres://postgres@127.0.0.1:55597/smrt_oauth pnpm --filter @happyvertical/smrt-users exec vitest run src/__tests__/oauth-authorization.test.ts
72
+ pnpm --filter @happyvertical/smrt-users typecheck
73
+ ```
74
+
75
+ | Invariant / trigger | Positive | Negative / failure | Actor / context | Executor / external edge | Level |
76
+ | --- | --- | --- | --- | --- | --- |
77
+ | Register and consume code | Metadata survives new adapter; one concurrent winner | Wrong client, redirect, resource; expiry and replay | Registered client / subject | Independent DB connections; SDK code contract | Integration |
78
+ | Rotate / replay refresh | One replacement | Reuse revokes every descendant | Same family across replicas | Stable family lock, same transaction | Integration |
79
+ | Rotation rollback / revoke race | Retry after insertion failure works | Duplicate replacement aborts consume; revoke cannot miss descendant | Same family, simultaneous operations | Real rollback and independent executors | Integration |
80
+ | Narrow replacement | Subset and replacement claims persist through subsequent rotation; undefined claims clear old claims | Expansion, hash mismatch, consumed/revoked token | Authenticated client / original family | SDK fail-closed narrowing contract, atomic locked update | Integration |
81
+ | Explicit recovery factory | Recreates configured connection after invalidation | Instance-only fails closed; ordinary storage failure propagates | Storage owner / configured database | Injected failure boundary plus real independent-connection races | Unit + integration |
82
+ | Access revocation | Another adapter sees it; repeats safe | Expired revocation no longer effective | Possessor of verified access token | Unique insert with conflict handling | Integration |
83
+ | Consent/grant ownership | Own grant can list/revoke | Other subject, tenant, scope deny; no session bearer in projection | Owner A/B × grant A/B | Live session + consent reads; atomic cascade | Integration |
84
+ | Live identity and ceiling | Current permission allows | Inactive user/membership, changed session ceiling, destroyed session | Active vs revoked context | SessionService and current RBAC | Integration |
85
+ | Code/live revoke cascade | Valid code exchanges | Membership loss before exchange; refresh revoke kills access | Original subject/tenant | SDK revalidateConsent callback | Integration |
86
+ | Consent snapshot / tenant | Original mapped scope | Later permission mapping expansion; inactive tenant | Owner × current tenant | Stored upper bound plus live permission checks | Integration |
87
+ | Layered session / expiry | Parent and child valid | Parent destroyed or child expired | Person session on device parent | Existing session controls | Integration |
88
+ | Consent route | Same-origin explicit approval | Missing/foreign Origin, denial, revoked session | Browser session / untrusted POST | Fetch request and SDK parsing | Route + DB |
89
+
90
+ This is new functionality, so a base-regression comparison is N/A: base has no
91
+ OAuth authorization storage or session grant API. Positive/negative contract
92
+ cases exercise real persistence rather than mocked SQL. Remote-provider outage
93
+ is N/A: the service has no external identity/provider call. SDK owns malformed
94
+ OAuth protocol, signature, PKCE and redirect parsing tests; hosts own their
95
+ consent UI, login return path and application-specific workspace policy.
96
+
97
+ ### Recoverable local SQLite connections
98
+
99
+ Local libsql connections may be invalidated by the SQL adapter after a native
100
+ `SQLITE_BUSY` failure. An instance-only storage adapter fails closed in this
101
+ case; it does not silently reconnect and discard connection settings.
102
+ For recoverable local multi-replica operation, supply `recoverDatabase`:
103
+
104
+ ```typescript
105
+ const storage = await SmrtOAuthAuthorizationStorage.create({
106
+ db,
107
+ recoverDatabase: async () => {
108
+ const connection = await getDatabase({
109
+ type: 'sqlite', url: configuredFileUrl, dbid: randomUUID(),
110
+ });
111
+ await connection.query('PRAGMA foreign_keys = ON');
112
+ // Reapply any other application-required connection settings here.
113
+ return connection;
114
+ },
115
+ });
116
+ ```
117
+
118
+ The factory is also used for the initial dedicated storage connection, keeping
119
+ storage recovery independent of a shared application/session connection. It
120
+ must return a migrated connection to the same database and restore every
121
+ required PRAGMA, attachment, temporary table or other per-connection setting.
122
+ It must not create application schema. Known invalidated failures may be retried
123
+ at most six times; ordinary storage failures propagate. PostgreSQL does not
124
+ need this factory. Connection ownership/disposal remains with the host factory.
@@ -57,3 +57,21 @@ reap them.
57
57
  marked `expired` by lazy expiry, and consumed history; it retains approved
58
58
  requests until exchange so a near-expiry approval cannot orphan its bearer
59
59
  session.
60
+
61
+ ## OAuth credential retention
62
+
63
+ `users-oauth-credentials` prunes expired authorization codes and access-token
64
+ revocations. Refresh grants (including consumed or revoked hashes) remain until
65
+ 30 days after their own expiry, so replay within their validity period and this
66
+ grace period still revokes the family. Later replay fails as an unknown token.
67
+ This bounds retained rotation history even for continuously active families.
68
+ The sweep locks each candidate family using the same row as token rotation,
69
+ rechecks its descendants, and deletes the family only when none remain. Live
70
+ grants and revocations are never swept. Each family is a separate transaction;
71
+ rolled-back contention retries follow the OAuth transaction policy. If the SQL
72
+ adapter invalidates a connection (for example SQLite SQLITE_BUSY), the sweep
73
+ reports failure; the host must acquire a fresh connection before retrying. It
74
+ never continues on an invalidated connection. Dry runs write nothing and
75
+ report approximate counts under concurrent writes. Clients and consent records
76
+ remain for application-managed lifecycle/audit. Opt-out and unregister work as
77
+ for the other retention tasks. No raw bearer or credential hash is logged.