@opendatalabs/personal-server-ts-server 1.33.8 → 1.34.1

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 (167) hide show
  1. package/dist/__fixtures__/bound-record-store.d.ts +19 -0
  2. package/dist/__fixtures__/bound-record-store.d.ts.map +1 -0
  3. package/dist/__fixtures__/bound-record-store.js +60 -0
  4. package/dist/__fixtures__/bound-record-store.js.map +1 -0
  5. package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.d.ts +41 -0
  6. package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.d.ts.map +1 -0
  7. package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.js +34 -0
  8. package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.js.map +1 -0
  9. package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.d.ts +112 -0
  10. package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.d.ts.map +1 -0
  11. package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.js +226 -0
  12. package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.js.map +1 -0
  13. package/dist/__fixtures__/cg-pdpp-client/pdpp-types.d.ts +135 -0
  14. package/dist/__fixtures__/cg-pdpp-client/pdpp-types.d.ts.map +1 -0
  15. package/dist/__fixtures__/cg-pdpp-client/pdpp-types.js +21 -0
  16. package/dist/__fixtures__/cg-pdpp-client/pdpp-types.js.map +1 -0
  17. package/dist/api-auth.d.ts +18 -2
  18. package/dist/api-auth.d.ts.map +1 -1
  19. package/dist/api-auth.js +114 -22
  20. package/dist/api-auth.js.map +1 -1
  21. package/dist/app.d.ts +60 -1
  22. package/dist/app.d.ts.map +1 -1
  23. package/dist/app.js +139 -1
  24. package/dist/app.js.map +1 -1
  25. package/dist/bootstrap.d.ts +10 -0
  26. package/dist/bootstrap.d.ts.map +1 -1
  27. package/dist/bootstrap.js +359 -8
  28. package/dist/bootstrap.js.map +1 -1
  29. package/dist/enclave-main.d.ts +4 -0
  30. package/dist/enclave-main.d.ts.map +1 -1
  31. package/dist/enclave-main.js +152 -1
  32. package/dist/enclave-main.js.map +1 -1
  33. package/dist/middleware/body-limit.d.ts +1 -1
  34. package/dist/middleware/body-limit.d.ts.map +1 -1
  35. package/dist/middleware/body-limit.js +8 -3
  36. package/dist/middleware/body-limit.js.map +1 -1
  37. package/dist/middleware/cors.d.ts.map +1 -1
  38. package/dist/middleware/cors.js +1 -0
  39. package/dist/middleware/cors.js.map +1 -1
  40. package/dist/package.json +9 -1
  41. package/dist/pdpp/bootstrap.d.ts +72 -0
  42. package/dist/pdpp/bootstrap.d.ts.map +1 -0
  43. package/dist/pdpp/bootstrap.js +270 -0
  44. package/dist/pdpp/bootstrap.js.map +1 -0
  45. package/dist/pdpp/chain-enforcement.d.ts +95 -0
  46. package/dist/pdpp/chain-enforcement.d.ts.map +1 -0
  47. package/dist/pdpp/chain-enforcement.js +145 -0
  48. package/dist/pdpp/chain-enforcement.js.map +1 -0
  49. package/dist/pdpp/client-document-fetch.d.ts +63 -0
  50. package/dist/pdpp/client-document-fetch.d.ts.map +1 -0
  51. package/dist/pdpp/client-document-fetch.js +254 -0
  52. package/dist/pdpp/client-document-fetch.js.map +1 -0
  53. package/dist/pdpp/declaration-registry.d.ts +83 -0
  54. package/dist/pdpp/declaration-registry.d.ts.map +1 -0
  55. package/dist/pdpp/declaration-registry.js +251 -0
  56. package/dist/pdpp/declaration-registry.js.map +1 -0
  57. package/dist/pdpp/deployment.d.ts +70 -0
  58. package/dist/pdpp/deployment.d.ts.map +1 -0
  59. package/dist/pdpp/deployment.js +104 -0
  60. package/dist/pdpp/deployment.js.map +1 -0
  61. package/dist/pdpp/record-schema.d.ts +2 -0
  62. package/dist/pdpp/record-schema.d.ts.map +1 -0
  63. package/dist/pdpp/record-schema.js +2 -0
  64. package/dist/pdpp/record-schema.js.map +1 -0
  65. package/dist/pdpp/records-bootstrap.d.ts +111 -0
  66. package/dist/pdpp/records-bootstrap.d.ts.map +1 -0
  67. package/dist/pdpp/records-bootstrap.js +353 -0
  68. package/dist/pdpp/records-bootstrap.js.map +1 -0
  69. package/dist/pdpp/source-id-compat.d.ts +5 -0
  70. package/dist/pdpp/source-id-compat.d.ts.map +1 -0
  71. package/dist/pdpp/source-id-compat.js +16 -0
  72. package/dist/pdpp/source-id-compat.js.map +1 -0
  73. package/dist/pdpp/test-connections.d.ts +6 -0
  74. package/dist/pdpp/test-connections.d.ts.map +1 -0
  75. package/dist/pdpp/test-connections.js +37 -0
  76. package/dist/pdpp/test-connections.js.map +1 -0
  77. package/dist/routes/auth-device.d.ts.map +1 -1
  78. package/dist/routes/auth-device.js +3 -0
  79. package/dist/routes/auth-device.js.map +1 -1
  80. package/dist/routes/chunked-scope-import.d.ts +41 -0
  81. package/dist/routes/chunked-scope-import.d.ts.map +1 -0
  82. package/dist/routes/chunked-scope-import.js +901 -0
  83. package/dist/routes/chunked-scope-import.js.map +1 -0
  84. package/dist/routes/data.d.ts +23 -1
  85. package/dist/routes/data.d.ts.map +1 -1
  86. package/dist/routes/data.js +112 -1
  87. package/dist/routes/data.js.map +1 -1
  88. package/dist/routes/grants.d.ts.map +1 -1
  89. package/dist/routes/grants.js +2 -0
  90. package/dist/routes/grants.js.map +1 -1
  91. package/dist/routes/mcp.d.ts.map +1 -1
  92. package/dist/routes/mcp.js +3 -0
  93. package/dist/routes/mcp.js.map +1 -1
  94. package/dist/routes/pdpp-auth.d.ts +157 -0
  95. package/dist/routes/pdpp-auth.d.ts.map +1 -0
  96. package/dist/routes/pdpp-auth.js +953 -0
  97. package/dist/routes/pdpp-auth.js.map +1 -0
  98. package/dist/routes/pdpp-blobs.d.ts +26 -0
  99. package/dist/routes/pdpp-blobs.d.ts.map +1 -0
  100. package/dist/routes/pdpp-blobs.js +179 -0
  101. package/dist/routes/pdpp-blobs.js.map +1 -0
  102. package/dist/routes/pdpp-connections.d.ts +28 -0
  103. package/dist/routes/pdpp-connections.d.ts.map +1 -0
  104. package/dist/routes/pdpp-connections.js +144 -0
  105. package/dist/routes/pdpp-connections.js.map +1 -0
  106. package/dist/routes/pdpp-declarations.d.ts +58 -0
  107. package/dist/routes/pdpp-declarations.d.ts.map +1 -0
  108. package/dist/routes/pdpp-declarations.js +94 -0
  109. package/dist/routes/pdpp-declarations.js.map +1 -0
  110. package/dist/routes/pdpp-instance-bindings.d.ts +26 -0
  111. package/dist/routes/pdpp-instance-bindings.d.ts.map +1 -0
  112. package/dist/routes/pdpp-instance-bindings.js +124 -0
  113. package/dist/routes/pdpp-instance-bindings.js.map +1 -0
  114. package/dist/routes/pdpp-records.d.ts +158 -0
  115. package/dist/routes/pdpp-records.d.ts.map +1 -0
  116. package/dist/routes/pdpp-records.js +1537 -0
  117. package/dist/routes/pdpp-records.js.map +1 -0
  118. package/dist/routes/pdpp-well-known.d.ts +13 -0
  119. package/dist/routes/pdpp-well-known.d.ts.map +1 -0
  120. package/dist/routes/pdpp-well-known.js +20 -0
  121. package/dist/routes/pdpp-well-known.js.map +1 -0
  122. package/dist/storage/declaration-witness.d.ts +56 -0
  123. package/dist/storage/declaration-witness.d.ts.map +1 -0
  124. package/dist/storage/declaration-witness.js +273 -0
  125. package/dist/storage/declaration-witness.js.map +1 -0
  126. package/dist/storage/hierarchy.d.ts +25 -1
  127. package/dist/storage/hierarchy.d.ts.map +1 -1
  128. package/dist/storage/hierarchy.js +210 -16
  129. package/dist/storage/hierarchy.js.map +1 -1
  130. package/dist/storage/import-operations.d.ts +11 -0
  131. package/dist/storage/import-operations.d.ts.map +1 -0
  132. package/dist/storage/import-operations.js +261 -0
  133. package/dist/storage/import-operations.js.map +1 -0
  134. package/dist/storage/index-manager.d.ts +24 -2
  135. package/dist/storage/index-manager.d.ts.map +1 -1
  136. package/dist/storage/index-manager.js +279 -30
  137. package/dist/storage/index-manager.js.map +1 -1
  138. package/dist/storage/index-schema.d.ts +1 -1
  139. package/dist/storage/index-schema.d.ts.map +1 -1
  140. package/dist/storage/index-schema.js +189 -8
  141. package/dist/storage/index-schema.js.map +1 -1
  142. package/dist/storage/legacy-scope-authority.d.ts +14 -0
  143. package/dist/storage/legacy-scope-authority.d.ts.map +1 -0
  144. package/dist/storage/legacy-scope-authority.js +298 -0
  145. package/dist/storage/legacy-scope-authority.js.map +1 -0
  146. package/dist/storage/node-data-storage.d.ts +6 -0
  147. package/dist/storage/node-data-storage.d.ts.map +1 -1
  148. package/dist/storage/node-data-storage.js +161 -2
  149. package/dist/storage/node-data-storage.js.map +1 -1
  150. package/dist/storage/pdpp-grant-binding-sqlite-store.d.ts +17 -0
  151. package/dist/storage/pdpp-grant-binding-sqlite-store.d.ts.map +1 -0
  152. package/dist/storage/pdpp-grant-binding-sqlite-store.js +159 -0
  153. package/dist/storage/pdpp-grant-binding-sqlite-store.js.map +1 -0
  154. package/dist/storage/pdpp-records-sqlite-store.d.ts +112 -0
  155. package/dist/storage/pdpp-records-sqlite-store.d.ts.map +1 -0
  156. package/dist/storage/pdpp-records-sqlite-store.js +1440 -0
  157. package/dist/storage/pdpp-records-sqlite-store.js.map +1 -0
  158. package/dist/storage/pdpp-stream-completion.d.ts +5 -0
  159. package/dist/storage/pdpp-stream-completion.d.ts.map +1 -0
  160. package/dist/storage/pdpp-stream-completion.js +10 -0
  161. package/dist/storage/pdpp-stream-completion.js.map +1 -0
  162. package/dist/storage/scope-deletion-history.d.ts +8 -0
  163. package/dist/storage/scope-deletion-history.d.ts.map +1 -0
  164. package/dist/storage/scope-deletion-history.js +40 -0
  165. package/dist/storage/scope-deletion-history.js.map +1 -0
  166. package/dist/ui/ps-lite-debug.js +3981 -1109
  167. package/package.json +12 -4
@@ -0,0 +1,953 @@
1
+ /**
2
+ * PDPP Core v0.1.0 Authorization Server HTTP surface.
3
+ *
4
+ * The endpoints under `/pdpp/v1`:
5
+ *
6
+ * POST /authorize — accept an RFC 9396 selection request
7
+ * GET /authorize/:session_id/review — the owner's consent review model
8
+ * POST /authorize/:session_id/approve — authenticated owner approval
9
+ * POST /authorize/:session_id/deny — authenticated owner denial
10
+ * POST /token — code redemption + refresh rotation
11
+ * POST /introspect — RFC 7662 introspection
12
+ * GET /grants — the owner's grants, with status
13
+ * POST /revoke — owner-initiated grant revocation
14
+ *
15
+ * This is deliberately a NEW surface rather than an extension of
16
+ * `oauth-token.ts`. That endpoint serves the control-plane and CLI device
17
+ * flows against the legacy `TokenStore`; PDPP tokens are grant-bound and live
18
+ * in their own store with their own lifecycle. Mixing them would mean one
19
+ * endpoint with two token authorities, which §8 explicitly warns against
20
+ * ("do not query or create a second grant authority"). Existing clients of
21
+ * `/oauth/token` are untouched.
22
+ *
23
+ * Every response carries `Cache-Control: no-store` and `Pragma: no-cache`
24
+ * (RFC 6749 §5.1 for token responses; delivery scope §1 requires it on every
25
+ * token response specifically).
26
+ *
27
+ * Two things that cost integrators a debug cycle, so stated plainly:
28
+ *
29
+ * - **Content types differ by endpoint.** `/authorize`, `/approve` and
30
+ * `/deny` take JSON. `/token`, `/introspect` and `/revoke` take
31
+ * `application/x-www-form-urlencoded`, because they follow the RFC 6749 /
32
+ * 7662 / 7009 wire conventions a standard OAuth client library already
33
+ * speaks. Sending JSON to the form endpoints parses to an empty body and
34
+ * reads as a missing parameter.
35
+ * - **The review digest is nested.** `GET /authorize/:id/review` returns
36
+ * `{ session_id, review, expires_at }`, so the digest is at
37
+ * `review.review_digest`, not at the top level. The envelope also carries
38
+ * `instance_choice_required` instead of `review` when the owner still has
39
+ * an instance to pick.
40
+ */
41
+ import { Hono } from "hono";
42
+ import { approveAuthorization, denyAuthorization, fetchReview, PDPP_API_VERSION, PDPP_DATA_ACCESS_TYPE, PDPP_DATA_ACCESS_TYPE_V02, resolveRequesterIdentity, validateCodeChallenge, validateRedirectUri, validateSelectionRequest, } from "@opendatalabs/personal-server-ts-core/pdpp";
43
+ import { createWeb3AuthMiddleware } from "../middleware/web3-auth.js";
44
+ import { createOwnerCheckMiddleware } from "../middleware/owner-check.js";
45
+ import { createBodyLimit, DEFAULT_MAX_SIZE } from "../middleware/body-limit.js";
46
+ import { canonicalSourceIdCandidate } from "../pdpp/source-id-compat.js";
47
+ /** No-store on everything: tokens, codes, and review models are all sensitive. */
48
+ const NO_STORE = {
49
+ "Cache-Control": "no-store",
50
+ Pragma: "no-cache",
51
+ };
52
+ /** Owner tokens are short-lived: they authorize consent decisions. */
53
+ export const OWNER_TOKEN_TTL_SECONDS = 15 * 60;
54
+ function errorResponse(c, status, error, description) {
55
+ return c.json({ error, error_description: description }, status, NO_STORE);
56
+ }
57
+ /** Bearer token from the Authorization header, if present. */
58
+ function bearer(c) {
59
+ const header = c.req.header("authorization");
60
+ if (!header?.toLowerCase().startsWith("bearer "))
61
+ return undefined;
62
+ const token = header.slice(7).trim();
63
+ return token.length > 0 ? token : undefined;
64
+ }
65
+ function resolveScopedOwnerToken(deps, ownerToken, subjectId, sourceId) {
66
+ if (!ownerToken) {
67
+ return {
68
+ ok: false,
69
+ failure: {
70
+ code: "unauthorized",
71
+ message: "a scoped owner token is required",
72
+ },
73
+ };
74
+ }
75
+ const context = deps.tokens.resolveToken(ownerToken);
76
+ if (!context.active || context.tokenKind !== "owner" || !context.subjectId) {
77
+ return {
78
+ ok: false,
79
+ failure: {
80
+ code: "unauthorized",
81
+ message: "an active scoped owner token is required",
82
+ },
83
+ };
84
+ }
85
+ if (subjectId && context.subjectId !== subjectId) {
86
+ return {
87
+ ok: false,
88
+ failure: {
89
+ code: "session_not_found",
90
+ message: "authorization session not found",
91
+ },
92
+ };
93
+ }
94
+ if (context.sourceId && sourceId && context.sourceId !== sourceId) {
95
+ return {
96
+ ok: false,
97
+ failure: {
98
+ code: "access_denied",
99
+ message: "owner token is not scoped to the requested source",
100
+ },
101
+ };
102
+ }
103
+ return {
104
+ ok: true,
105
+ subjectId: context.subjectId,
106
+ ...(context.sourceId && { sourceId: context.sourceId }),
107
+ };
108
+ }
109
+ function scopedOwnerCoversSnapshot(deps, subjectId, snapshot) {
110
+ const inventory = deps.inventoryFor(subjectId, snapshot.source_id);
111
+ return snapshot.streams.some((stream) => inventory.eligibleFor(stream.name).length > 0);
112
+ }
113
+ function grantContainedByOwnerScope(subjectId, stored, ownerSourceId, ownerInstanceIds) {
114
+ return (stored.subjectId === subjectId &&
115
+ (!ownerSourceId || stored.grant.source.id === ownerSourceId) &&
116
+ (!ownerInstanceIds ||
117
+ stored.grant.streams.every((stream) => stream.instance_ids.every((instance) => ownerInstanceIds.includes(instance)))));
118
+ }
119
+ function scopedOwnerFailureResponse(c, failure) {
120
+ return errorResponse(c, failure.code === "access_denied" ? 403 : approvalStatus(failure.code), approvalOAuthError(failure.code), failure.message);
121
+ }
122
+ /**
123
+ * Negotiate `PDPP-Version` (§7 version layering, §9 AS item 17).
124
+ *
125
+ * An absent header selects the current stable version. An unsupported one is
126
+ * a 400 `unsupported_version` — not a silent downgrade, because a client that
127
+ * asked for a version it needs must not be handed a different contract.
128
+ */
129
+ function negotiateVersion(c) {
130
+ const requested = c.req.header("pdpp-version");
131
+ if (!requested)
132
+ return { ok: true };
133
+ if (requested !== PDPP_API_VERSION)
134
+ return { ok: false, requested };
135
+ return { ok: true };
136
+ }
137
+ /**
138
+ * The PDPP AS metadata document, as a handler both of its locations share.
139
+ *
140
+ * Exported because the two URLs it is served at live in different routers: the
141
+ * path-first one inside `/pdpp/v1`, and the RFC 8414 §3 one
142
+ * (`/.well-known/oauth-authorization-server/pdpp/v1`) at the app root, outside
143
+ * this prefix. One handler rather than two so the documents can never drift --
144
+ * a client discovering through either must find the same authority.
145
+ *
146
+ * `issuer` is accepted as a thunk for deployments that resolve their own origin
147
+ * late (a bound port unknown at construction). It is resolved per request so
148
+ * the document never advertises a stale origin.
149
+ */
150
+ export function pdppAsMetadataHandler(issuerSource) {
151
+ return (c) => {
152
+ const issuer = typeof issuerSource === "function" ? issuerSource() : issuerSource;
153
+ if (!issuer) {
154
+ return errorResponse(c, 404, "not_found", "this deployment does not publish PDPP authorization-server metadata");
155
+ }
156
+ const base = `${issuer.replace(/\/$/, "")}/pdpp/v1`;
157
+ return c.json({
158
+ issuer,
159
+ authorization_endpoint: `${base}/authorize`,
160
+ token_endpoint: `${base}/token`,
161
+ introspection_endpoint: `${base}/introspect`,
162
+ revocation_endpoint: `${base}/revoke`,
163
+ authorization_details_types_supported: [
164
+ PDPP_DATA_ACCESS_TYPE,
165
+ PDPP_DATA_ACCESS_TYPE_V02,
166
+ ],
167
+ grant_types_supported: ["authorization_code", "refresh_token"],
168
+ response_types_supported: ["code"],
169
+ // PKCE is required on this flow, not merely supported: PDPP clients
170
+ // are public clients, so an intercepted code without a verifier is
171
+ // redeemable by whoever intercepted it.
172
+ code_challenge_methods_supported: ["S256"],
173
+ pdpp_api_version: PDPP_API_VERSION,
174
+ }, 200, NO_STORE);
175
+ };
176
+ }
177
+ export function pdppAuthRoutes(deps) {
178
+ const app = new Hono();
179
+ // The selected version echoes back on every response (§9 AS item 17).
180
+ app.use("*", async (c, next) => {
181
+ const version = negotiateVersion(c);
182
+ if (!version.ok) {
183
+ return errorResponse(c, 400, "unsupported_version", `PDPP-Version '${version.requested}' is not supported; this server implements ${PDPP_API_VERSION}`);
184
+ }
185
+ await next();
186
+ c.header("PDPP-Version", PDPP_API_VERSION);
187
+ });
188
+ // The owner-proof chain, when a deployment wires one. `web3-auth` verifies
189
+ // the Web3Signed wallet signature and populates `c.get("auth")`;
190
+ // `owner-check` compares the recovered signer against the configured server
191
+ // owner. Scoped to the token-exchange path only — the rest of the surface
192
+ // authenticates with the PDPP owner token that exchange produces.
193
+ if (deps.ownerAuth) {
194
+ app.use("/owner/token", createBodyLimit(DEFAULT_MAX_SIZE));
195
+ app.use("/owner/token", createWeb3AuthMiddleware({
196
+ serverOrigin: deps.ownerAuth.serverOrigin,
197
+ devToken: deps.ownerAuth.devToken,
198
+ accessToken: deps.ownerAuth.accessToken,
199
+ tokenStore: deps.ownerAuth.tokenStore,
200
+ serverOwner: deps.ownerAuth.serverOwner,
201
+ }));
202
+ app.use("/owner/token", createOwnerCheckMiddleware(deps.ownerAuth.serverOwner));
203
+ }
204
+ /**
205
+ * OAuth authorization-server metadata for the PDPP AS.
206
+ *
207
+ * v0.2 §8 requires an AS supporting the revision to advertise
208
+ * `https://pdpp.dev/data-access/0.2` in
209
+ * `authorization_details_types_supported`, and requires a client to
210
+ * establish support before requesting the type. Without this document a
211
+ * conformant client must not request v0.2 at all, so the implementation
212
+ * would be unreachable to exactly the clients that follow the spec.
213
+ *
214
+ * Both types are advertised: both are implemented, each resolves under its
215
+ * own revision, and dropping v0.1 would strand existing clients.
216
+ *
217
+ * This sits under `/pdpp/v1/` rather than at the origin root because the
218
+ * root document already describes the MCP OAuth authorization server — a
219
+ * different authority over different tokens. §8 warns against a second grant
220
+ * authority; merging the documents would present the two as one.
221
+ *
222
+ * The SAME document is also served at the RFC 8414 §3 location
223
+ * (`/.well-known/oauth-authorization-server/pdpp/v1` — well-known first, the
224
+ * issuer's path appended), mounted by the app because it lies outside this
225
+ * router's prefix. Publishing only the path-first form above left the
226
+ * document undiscoverable to any client that follows RFC 8414, which is the
227
+ * only discovery rule the spec gives. The RFC location keeps the `/pdpp/v1`
228
+ * suffix, so it does not collide with the bare URL the MCP AS owns and the
229
+ * separation this comment describes is preserved.
230
+ */
231
+ app.get("/.well-known/oauth-authorization-server", pdppAsMetadataHandler(deps.issuer));
232
+ /**
233
+ * Exchange an already-verified owner proof for a PDPP owner token.
234
+ *
235
+ * This endpoint mints no authority of its own. The caller must already have
236
+ * satisfied the PS's existing owner proof — the `web3-auth` middleware
237
+ * verifies a Web3Signed wallet signature and `owner-check` compares the
238
+ * recovered signer against the configured server owner. This route only
239
+ * converts that proof into the short-lived, grant-system credential the
240
+ * consent decision endpoints require.
241
+ *
242
+ * The indirection is deliberate and is the whole point of the design in
243
+ * `approval.ts`: a consent broker (Account/Web) never mints an owner token
244
+ * and never asserts owner identity. It holds a credential the PS issued to
245
+ * a verified wallet signer, so compromising the broker yields a token that
246
+ * expires, not the authority to approve anything for any subject.
247
+ *
248
+ * Mount this behind the same middleware chain as other owner routes:
249
+ *
250
+ * app.use("/pdpp/v1/owner/token", createWeb3AuthMiddleware(...));
251
+ * app.use("/pdpp/v1/owner/token", createOwnerCheckMiddleware(serverOwner));
252
+ *
253
+ * `ownerSubjectId` resolves the verified signer to the PDPP subject; a
254
+ * deployment that maps wallets to subjects differently supplies its own.
255
+ */
256
+ app.post("/owner/token", async (c) => {
257
+ // With `ownerAuth` wired, the middleware above has already verified a
258
+ // wallet signature and confirmed the signer is the server owner, so
259
+ // `c.get("auth").signer` is a proven identity rather than a claim.
260
+ const verifiedSigner = c.get("auth")
261
+ ?.signer;
262
+ const subjectId = deps.ownerSubjectId?.(c) ??
263
+ (deps.ownerAuth && verifiedSigner ? verifiedSigner : null) ??
264
+ deps.currentSubjectId(c);
265
+ if (!subjectId) {
266
+ // Reaching here means the owner middleware did not run or did not
267
+ // populate a verified signer. Fail closed rather than inventing a
268
+ // subject — an owner token for an unidentified subject is exactly the
269
+ // credential this design exists to prevent.
270
+ return errorResponse(c, 401, "unauthorized", "a verified owner proof is required to obtain a PDPP owner token");
271
+ }
272
+ let body;
273
+ try {
274
+ body = await c.req.json();
275
+ }
276
+ catch {
277
+ return errorResponse(c, 400, "invalid_request", "owner token exchange requires JSON body {source_id}");
278
+ }
279
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
280
+ return errorResponse(c, 400, "invalid_request", "owner token exchange requires JSON body {source_id}");
281
+ }
282
+ const tokenRequest = body;
283
+ const sourceId = tokenRequest.source_id;
284
+ if (typeof sourceId !== "string" ||
285
+ Object.keys(tokenRequest).some((key) => key !== "source_id" && key !== "instance_id") ||
286
+ (tokenRequest.instance_id !== undefined &&
287
+ (typeof tokenRequest.instance_id !== "string" ||
288
+ tokenRequest.instance_id.length === 0))) {
289
+ return errorResponse(c, 400, "invalid_request", "owner token exchange accepts source_id and an optional instance_id");
290
+ }
291
+ const canonicalCandidate = canonicalSourceIdCandidate(sourceId);
292
+ const declaration = deps.resolveDeclaration(sourceId) ??
293
+ (canonicalCandidate ? deps.resolveDeclaration(canonicalCandidate) : null);
294
+ const connectionSourceId = deps.resolveConnectionSourceId?.(sourceId) ??
295
+ (canonicalCandidate
296
+ ? deps.resolveConnectionSourceId?.(canonicalCandidate)
297
+ : undefined);
298
+ const resolvedSourceId = declaration?.source_id ?? connectionSourceId;
299
+ if (!resolvedSourceId) {
300
+ return errorResponse(c, 404, "not_found", "source is not enabled on this server");
301
+ }
302
+ const issued = deps.tokens.issueOwnerToken({
303
+ subjectId,
304
+ sourceId: resolvedSourceId,
305
+ ...(typeof tokenRequest.instance_id === "string" && {
306
+ instanceIds: [tokenRequest.instance_id],
307
+ }),
308
+ ttlSeconds: OWNER_TOKEN_TTL_SECONDS,
309
+ });
310
+ deps.logger.info({ subject_id: subjectId }, "PDPP owner token issued to a verified owner");
311
+ return c.json(issued, 200, NO_STORE);
312
+ });
313
+ /**
314
+ * Accept an RFC 9396 selection request and open an authorization session.
315
+ *
316
+ * Requires an authenticated owner: the session is bound to that subject at
317
+ * creation, which is what makes the later approval check meaningful. A
318
+ * session created for an unauthenticated caller would have no subject to
319
+ * bind an approval against.
320
+ */
321
+ app.post("/authorize", async (c) => {
322
+ const subjectId = deps.currentSubjectId(c);
323
+ if (!subjectId) {
324
+ return errorResponse(c, 401, "unauthorized", "an authenticated owner session is required to start an authorization");
325
+ }
326
+ let body;
327
+ try {
328
+ body = await c.req.json();
329
+ }
330
+ catch {
331
+ return errorResponse(c, 400, "invalid_request", "body must be JSON");
332
+ }
333
+ const details = body.authorization_details;
334
+ if (!Array.isArray(details) || details.length !== 1) {
335
+ // v0.1 issues one grant per authorization. A package of several details
336
+ // would need package-level access-mode rules (§9 AS item 20) that Core
337
+ // does not yet pin down, so we reject rather than guess.
338
+ return errorResponse(c, 400, "invalid_authorization_details", "exactly one authorization_details entry is supported");
339
+ }
340
+ if (!body.client_id || !body.redirect_uri) {
341
+ return errorResponse(c, 400, "invalid_request", "client_id and redirect_uri are required");
342
+ }
343
+ // The authorization code travels in this redirect, so an unvalidated
344
+ // target is code exfiltration, not just an open redirect. PKCE does not
345
+ // help: an attacker who chose the redirect also chose the challenge and
346
+ // holds the verifier. RFC 6749 §4.1.2.1 forbids reporting this failure BY
347
+ // redirecting, so it is returned directly to the caller.
348
+ // Local registration has highest precedence and costs no network call.
349
+ // Only when a client is NOT registered does §6 require us to try its
350
+ // URL-hosted identity: rejecting solely for absence of preregistration is
351
+ // the one reason the spec names as insufficient.
352
+ let redirectPolicy = deps.registeredClient?.(body.client_id) ?? null;
353
+ // The document that earned redirect admission, carried forward so the
354
+ // consent surface shows the identity the verified domain asserted rather
355
+ // than whatever the client put in `client_display`. §6 ranks validated
356
+ // binding metadata above inline metadata; admitting on the document and
357
+ // then displaying the inline name would break that.
358
+ let validatedClientDocument;
359
+ if (!redirectPolicy && deps.resolveClientIdentity) {
360
+ const resolved = await deps.resolveClientIdentity(body.client_id);
361
+ if (resolved.ok) {
362
+ redirectPolicy = resolved.policy;
363
+ validatedClientDocument = resolved.document;
364
+ }
365
+ else {
366
+ // The refusal names the actual reason -- untrusted URL, unreachable,
367
+ // mismatched document -- rather than "not registered", so an operator
368
+ // can tell a policy denial from a broken client.
369
+ deps.logger.warn({ client_id: body.client_id, reason: resolved.failure.code }, "PDPP authorization refused: URL-hosted client identity rejected");
370
+ }
371
+ }
372
+ const redirectFailure = validateRedirectUri(body.redirect_uri, redirectPolicy);
373
+ if (redirectFailure) {
374
+ deps.logger.warn({
375
+ client_id: body.client_id,
376
+ redirect_uri: body.redirect_uri,
377
+ reason: redirectFailure.code,
378
+ }, "PDPP authorization refused: redirect_uri failed validation");
379
+ return errorResponse(c, 400, "invalid_request", redirectFailure.message);
380
+ }
381
+ // PKCE is validated before consent, not at redemption: a client whose flow
382
+ // is unusable should learn that before a human is asked to decide anything.
383
+ const pkceFailure = validateCodeChallenge(body.code_challenge, body.code_challenge_method, { required: deps.requirePkce ?? true });
384
+ if (pkceFailure) {
385
+ return errorResponse(c, 400, "invalid_request", pkceFailure.message);
386
+ }
387
+ const request = details[0];
388
+ const snapshot = deps.resolveDeclaration(request.source?.id ?? "");
389
+ if (!snapshot) {
390
+ return errorResponse(c, 400, "invalid_authorization_details", "no retained declaration snapshot for the requested source");
391
+ }
392
+ const owner = resolveScopedOwnerToken(deps, bearer(c), subjectId, snapshot.source_id);
393
+ if (!owner.ok) {
394
+ return scopedOwnerFailureResponse(c, owner.failure);
395
+ }
396
+ if (!scopedOwnerCoversSnapshot(deps, subjectId, snapshot)) {
397
+ return errorResponse(c, 403, "access_denied", "owner token is not scoped to the requested source");
398
+ }
399
+ const validation = validateSelectionRequest(request, snapshot);
400
+ if (!validation.ok) {
401
+ // §9 AS item 5: the binding maps a Source validation failure to RFC 9396
402
+ // `invalid_authorization_details`. Other shape failures map to
403
+ // `invalid_request`.
404
+ //
405
+ // v0.2 names the same code for a malformed minimum ("Malformed or
406
+ // unsupported authorization details instead produce
407
+ // `invalid_authorization_details`"), and `invalid_minimum` reaches it
408
+ // through this default. That is deliberately the *shape* half of the
409
+ // v0.2 error split: an unsatisfiable-but-well-formed minimum is an
410
+ // owner-decision outcome and surfaces at approval as `access_denied`,
411
+ // not here.
412
+ const oauthError = validation.failure.code === "invalid_request"
413
+ ? "invalid_request"
414
+ : "invalid_authorization_details";
415
+ return errorResponse(c, 400, oauthError, validation.failure.message);
416
+ }
417
+ const session = deps.sessions.create({
418
+ subjectId,
419
+ request,
420
+ snapshot,
421
+ requester: resolveRequesterIdentity({
422
+ client_id: body.client_id,
423
+ document: validatedClientDocument,
424
+ inline: body.client_display,
425
+ }),
426
+ redirectUri: body.redirect_uri,
427
+ stateParam: body.state,
428
+ codeChallenge: body.code_challenge,
429
+ codeChallengeMethod: body.code_challenge_method,
430
+ grantExpiresAt: deps.grantExpiryFor?.(request, body.client_id),
431
+ });
432
+ deps.logger.info({ session_id: session.session_id, client_id: body.client_id }, "PDPP authorization session opened");
433
+ return c.json({ session_id: session.session_id, expires_at: session.expires_at }, 201, NO_STORE);
434
+ });
435
+ /**
436
+ * The consent review model, for the authenticated owner only.
437
+ *
438
+ * When a stream has several eligible instances and the request named none,
439
+ * the response carries `instance_choice_required` instead of a review: the
440
+ * owner picks, then re-fetches with `?instance[<stream>]=<handle>` repeated
441
+ * per handle. §6 forbids inferring fan-in from omission, so this is a
442
+ * consent step rather than a failure.
443
+ */
444
+ app.get("/authorize/:session_id/review", (c) => {
445
+ const sessionId = c.req.param("session_id");
446
+ const session = deps.sessions.get(sessionId);
447
+ const owner = resolveScopedOwnerToken(deps, bearer(c), session?.subject_id, session?.snapshot.source_id);
448
+ if (!owner.ok) {
449
+ return scopedOwnerFailureResponse(c, owner.failure);
450
+ }
451
+ if (session &&
452
+ !scopedOwnerCoversSnapshot(deps, session.subject_id, session.snapshot)) {
453
+ return errorResponse(c, 403, "access_denied", "owner token is not scoped to the requested source");
454
+ }
455
+ const result = fetchReview({
456
+ sessions: deps.sessions,
457
+ tokens: deps.tokens,
458
+ sessionId,
459
+ ownerToken: bearer(c),
460
+ inventory: deps.inventoryFor(session?.subject_id ?? "", session?.snapshot.source_id ?? ""),
461
+ instanceChoices: parseInstanceChoices(c),
462
+ ownerChoices: parseOwnerChoices(c),
463
+ ownerConditions: parseOwnerConditions(c),
464
+ standingTerms: deps.standingTermsFor?.(session?.requester.client_id ?? "") ??
465
+ undefined,
466
+ store: deps.store,
467
+ existingGrantInstanceIds: session
468
+ ? Array.from(new Set(session.snapshot.streams.flatMap((stream) => deps
469
+ .inventoryFor(session.subject_id, session.snapshot.source_id)
470
+ .eligibleFor(stream.name))))
471
+ : [],
472
+ });
473
+ if (!result.ok) {
474
+ return errorResponse(c, approvalStatus(result.failure.code), approvalOAuthError(result.failure.code), result.failure.message);
475
+ }
476
+ return c.json(result.result, 200, NO_STORE);
477
+ });
478
+ /**
479
+ * Approve. Requires an authenticated owner token AND the review digest the
480
+ * owner was shown — see `approval.ts` for why affiliation is not enough.
481
+ */
482
+ app.post("/authorize/:session_id/approve", async (c) => {
483
+ const sessionId = c.req.param("session_id");
484
+ const session = deps.sessions.get(sessionId);
485
+ const owner = resolveScopedOwnerToken(deps, bearer(c), session?.subject_id, session?.snapshot.source_id);
486
+ if (!owner.ok) {
487
+ return scopedOwnerFailureResponse(c, owner.failure);
488
+ }
489
+ if (session &&
490
+ !scopedOwnerCoversSnapshot(deps, session.subject_id, session.snapshot)) {
491
+ return errorResponse(c, 403, "access_denied", "owner token is not scoped to the requested source");
492
+ }
493
+ let body;
494
+ try {
495
+ body = await c.req.json();
496
+ }
497
+ catch {
498
+ return errorResponse(c, 400, "invalid_request", "body must be JSON");
499
+ }
500
+ const result = approveAuthorization({
501
+ sessions: deps.sessions,
502
+ tokens: deps.tokens,
503
+ sessionId,
504
+ ownerToken: bearer(c),
505
+ reviewDigest: body.review_digest ?? "",
506
+ inventory: deps.inventoryFor(session?.subject_id ?? "", session?.snapshot.source_id ?? ""),
507
+ instanceChoices: body.instance_choices,
508
+ ownerChoices: body.owner_choices,
509
+ ownerConditions: body.owner_conditions,
510
+ standingTerms: deps.standingTermsFor?.(session?.requester.client_id ?? "") ??
511
+ undefined,
512
+ explicitAiTrainingConsent: body.explicit_ai_training_consent,
513
+ });
514
+ if (!result.ok) {
515
+ return errorResponse(c, approvalStatus(result.failure.code), approvalOAuthError(result.failure.code), result.failure.message);
516
+ }
517
+ // Persist the grant and its authorization code as one durable unit
518
+ // before marking this session complete. Only
519
+ // once this succeeds does the session move to "approved" — if the write
520
+ // fails, the session stays "pending" so the same reviewed approval can
521
+ // be retried rather than being stranded as already-decided with nothing
522
+ // durable behind it.
523
+ const code = `pdpp_code_${crypto.randomUUID().replace(/-/g, "")}`;
524
+ deps.store.insertGrantWithAuthCode({
525
+ grant: result.grant,
526
+ subjectId: result.grant.subject.id,
527
+ reviewDigest: result.consentEvidence.review_digest,
528
+ consentEvidence: result.consentEvidence,
529
+ code,
530
+ authCode: {
531
+ grantId: result.grant.grant_id,
532
+ clientId: result.grant.client.client_id,
533
+ redirectUri: session.redirect_uri,
534
+ // Carried from the authorization request, so redemption can prove the
535
+ // redeemer is the client that asked (RFC 7636 §4.4).
536
+ codeChallenge: session.code_challenge ?? null,
537
+ codeChallengeMethod: session.code_challenge_method ?? null,
538
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
539
+ },
540
+ });
541
+ deps.sessions.setStatus(session.session_id, "approved");
542
+ const redirect = new URL(session.redirect_uri);
543
+ redirect.searchParams.set("code", code);
544
+ if (session.state_param) {
545
+ redirect.searchParams.set("state", session.state_param);
546
+ }
547
+ deps.logger.info({ grant_id: result.grant.grant_id }, "PDPP grant issued after authenticated owner approval");
548
+ return c.json({ redirect_uri: redirect.toString(), grant_id: result.grant.grant_id }, 200, NO_STORE);
549
+ });
550
+ /** Deny. Terminal; no grant and no consent evidence beyond the denial. */
551
+ app.post("/authorize/:session_id/deny", async (c) => {
552
+ const sessionId = c.req.param("session_id");
553
+ const session = deps.sessions.get(sessionId);
554
+ const owner = resolveScopedOwnerToken(deps, bearer(c), session?.subject_id, session?.snapshot.source_id);
555
+ if (!owner.ok) {
556
+ return scopedOwnerFailureResponse(c, owner.failure);
557
+ }
558
+ if (session &&
559
+ !scopedOwnerCoversSnapshot(deps, session.subject_id, session.snapshot)) {
560
+ return errorResponse(c, 403, "access_denied", "owner token is not scoped to the requested source");
561
+ }
562
+ // The owner's optional note. Read defensively: a denial must succeed even
563
+ // when the body is absent or malformed, because refusing is the safe
564
+ // outcome and must never be blocked by the shape of an optional field.
565
+ let reason;
566
+ try {
567
+ const body = (await c.req.json());
568
+ if (typeof body?.reason === "string")
569
+ reason = body.reason;
570
+ }
571
+ catch {
572
+ reason = undefined;
573
+ }
574
+ const result = denyAuthorization({
575
+ sessions: deps.sessions,
576
+ tokens: deps.tokens,
577
+ sessionId,
578
+ ownerToken: bearer(c),
579
+ ...(reason !== undefined && { reason }),
580
+ });
581
+ if (!result.ok) {
582
+ return errorResponse(c, approvalStatus(result.failure.code), approvalOAuthError(result.failure.code), result.failure.message);
583
+ }
584
+ const redirect = new URL(session.redirect_uri);
585
+ redirect.searchParams.set("error", "access_denied");
586
+ if (session.state_param) {
587
+ redirect.searchParams.set("state", session.state_param);
588
+ }
589
+ return c.json({ redirect_uri: redirect.toString() }, 200, NO_STORE);
590
+ });
591
+ /**
592
+ * Token endpoint: authorization-code redemption and refresh rotation.
593
+ *
594
+ * Form-encoded per RFC 6749 §4.1.3 / §6, so a standard OAuth client library
595
+ * works against it unmodified.
596
+ */
597
+ app.post("/token", async (c) => {
598
+ const contentType = c.req.header("content-type") ?? "";
599
+ if (!contentType.includes("application/x-www-form-urlencoded")) {
600
+ return errorResponse(c, 400, "invalid_request", "Content-Type must be application/x-www-form-urlencoded");
601
+ }
602
+ const body = await c.req.parseBody();
603
+ const grantType = asString(body.grant_type);
604
+ if (grantType === "authorization_code") {
605
+ const code = asString(body.code);
606
+ const clientId = asString(body.client_id);
607
+ const redirectUri = asString(body.redirect_uri);
608
+ if (!code || !clientId || !redirectUri) {
609
+ return errorResponse(c, 400, "invalid_request", "code, client_id, and redirect_uri are required");
610
+ }
611
+ const result = deps.tokens.redeemAuthorizationCode({
612
+ code,
613
+ clientId,
614
+ redirectUri,
615
+ codeVerifier: asString(body.code_verifier) ?? undefined,
616
+ });
617
+ if (!result.ok) {
618
+ return errorResponse(c, 400, result.failure.code, result.failure.message);
619
+ }
620
+ return c.json(result.issued, 200, NO_STORE);
621
+ }
622
+ if (grantType === "refresh_token") {
623
+ const refreshToken = asString(body.refresh_token);
624
+ if (!refreshToken) {
625
+ return errorResponse(c, 400, "invalid_request", "refresh_token is required");
626
+ }
627
+ const result = deps.tokens.refresh({ refreshToken });
628
+ if (!result.ok) {
629
+ return errorResponse(c, 400, result.failure.code, result.failure.message);
630
+ }
631
+ return c.json(result.issued, 200, NO_STORE);
632
+ }
633
+ return errorResponse(c, 400, "unsupported_grant_type", `grant_type '${grantType ?? ""}' is not supported by the PDPP token endpoint`);
634
+ });
635
+ /**
636
+ * RFC 7662 introspection.
637
+ *
638
+ * **The co-located deployment is the supported baseline**, and there the RS
639
+ * calls `resolveToken` directly — this endpoint is not on that path.
640
+ *
641
+ * **Known gap for the separated deployment (§9 AS item 18).** The only
642
+ * credential accepted here is a PDPP *owner* token: 15-minute TTL, mintable
643
+ * only from a wallet owner-proof. A standalone Resource Server cannot hold
644
+ * one, so the deployment topology this endpoint exists to serve cannot
645
+ * actually authenticate to it. Closing that needs a distinct RS client
646
+ * identity (client credentials, mTLS, or a registered RS principal) that is
647
+ * not owner scope, which is a deployment-model decision this build does not
648
+ * make.
649
+ *
650
+ * Stated plainly rather than papered over: this AS does **not** claim
651
+ * conformance for separated AS/RS introspection. It is conformant for the
652
+ * co-located equivalent §8 explicitly permits ("A co-located AS and RS MAY
653
+ * resolve the same context through a local equivalent").
654
+ */
655
+ app.post("/introspect", async (c) => {
656
+ const callerToken = bearer(c);
657
+ if (!callerToken) {
658
+ return errorResponse(c, 401, "invalid_client", "the resource server must authenticate to introspect");
659
+ }
660
+ const caller = deps.tokens.resolveToken(callerToken);
661
+ if (!caller.active || caller.tokenKind !== "owner") {
662
+ return errorResponse(c, 401, "invalid_client", "introspection requires an authenticated resource server");
663
+ }
664
+ if (!caller.subjectId) {
665
+ return errorResponse(c, 403, "access_denied", "introspection requires an owner token");
666
+ }
667
+ const body = await c.req.parseBody();
668
+ const token = asString(body.token);
669
+ if (!token) {
670
+ // RFC 7662: a missing token is a request error, not an inactive answer.
671
+ return errorResponse(c, 400, "invalid_request", "token is required");
672
+ }
673
+ const target = deps.tokens.resolveToken(token);
674
+ if (target.active) {
675
+ if (target.grant) {
676
+ const stored = deps.store.getGrant(target.grant.grant_id);
677
+ if (!stored ||
678
+ stored.subjectId !== caller.subjectId ||
679
+ !grantContainedByOwnerScope(caller.subjectId, stored, caller.sourceId, caller.instanceIds)) {
680
+ return c.json({ active: false }, 200, NO_STORE);
681
+ }
682
+ }
683
+ else if (target.tokenKind !== "owner" ||
684
+ target.subjectId !== caller.subjectId ||
685
+ (caller.sourceId
686
+ ? target.sourceId !== caller.sourceId
687
+ : !caller.instanceIds ||
688
+ !target.instanceIds ||
689
+ !target.instanceIds.some((id) => caller.instanceIds?.includes(id)))) {
690
+ return c.json({ active: false }, 200, NO_STORE);
691
+ }
692
+ }
693
+ return c.json(deps.tokens.introspect(token), 200, NO_STORE);
694
+ });
695
+ /**
696
+ * List the owner's PDPP grants, newest first, with each one's status.
697
+ *
698
+ * Sits at `/grants` rather than under `/owner` because `/owner/*` is the
699
+ * wallet-proof-guarded path that mints owner tokens, while this route
700
+ * authenticates the same way `/revoke` does — with the owner token that
701
+ * exchange produced. It is the read half of the same owner grant-lifecycle
702
+ * pair, so it belongs beside its writer, not beside the credential mint.
703
+ *
704
+ * The projection is exactly the review's `existing_grants` shape plus
705
+ * `status` and `client_id`, so a surface that renders one can render the
706
+ * other. What it deliberately omits is everything that is authority or
707
+ * evidence rather than description: tokens, authorization codes, consent
708
+ * evidence and review digests. A grant list is for showing an owner what
709
+ * they have agreed to; none of those fields serve that, and each would turn
710
+ * a read-only listing into a credential leak.
711
+ *
712
+ * `status` widens `grantStatus` by one value the store models as a separate
713
+ * column: a `single_use` grant that has been redeemed reads `consumed`, not
714
+ * `active`, because it can never issue another token.
715
+ */
716
+ app.get("/grants", (c) => {
717
+ const caller = resolveOwner(c);
718
+ if (!caller.ok)
719
+ return caller.response;
720
+ const clientFilter = c.req.query("client_id");
721
+ const now = new Date();
722
+ const grants = deps.store
723
+ .listGrantsForSubject(caller.subjectId)
724
+ .filter((stored) => !clientFilter || stored.clientId === clientFilter)
725
+ .filter((stored) => grantContainedByOwnerScope(caller.subjectId, stored, caller.sourceId, caller.instanceIds))
726
+ .map((stored) => ({
727
+ grant_id: stored.grant.grant_id,
728
+ client_id: stored.clientId,
729
+ status: listedGrantStatus(deps.store, stored, now),
730
+ issued_at: stored.grant.issued_at,
731
+ ...(stored.grant.expires_at && { expires_at: stored.grant.expires_at }),
732
+ access_mode: stored.grant.access_mode,
733
+ purpose_code: stored.grant.purpose_code,
734
+ streams: stored.grant.streams.map((s) => ({
735
+ name: s.name,
736
+ fields: s.fields,
737
+ })),
738
+ }));
739
+ return c.json({ grants }, 200, NO_STORE);
740
+ });
741
+ /**
742
+ * Revoke a grant. Owner-authenticated, and scoped to the owner's own grants
743
+ * — a valid owner token is not authority over another subject's grant.
744
+ */
745
+ app.post("/revoke", async (c) => {
746
+ const caller = resolveOwner(c);
747
+ if (!caller.ok)
748
+ return caller.response;
749
+ const body = await c.req.parseBody();
750
+ const grantId = asString(body.grant_id);
751
+ if (!grantId) {
752
+ return errorResponse(c, 400, "invalid_request", "grant_id is required");
753
+ }
754
+ const stored = deps.store.getGrant(grantId);
755
+ // Not-found for another owner's grant as well, so a caller cannot probe
756
+ // which grant ids exist outside their own subject.
757
+ if (!stored ||
758
+ stored.subjectId !== caller.subjectId ||
759
+ !grantContainedByOwnerScope(caller.subjectId, stored, caller.sourceId, caller.instanceIds)) {
760
+ return errorResponse(c, 404, "not_found", "grant not found");
761
+ }
762
+ const revoked = deps.tokens.revokeGrant(grantId);
763
+ deps.logger.info({ grant_id: grantId, revoked }, "PDPP grant revocation");
764
+ // Idempotent: a second revoke is still "it is revoked".
765
+ return c.json({ grant_id: grantId, status: "revoked" }, 200, NO_STORE);
766
+ });
767
+ /**
768
+ * The single owner-token check the owner grant routes share.
769
+ *
770
+ * A client token is refused with the same 401 as no token at all: from the
771
+ * caller's side, holding the wrong kind of credential and holding none are
772
+ * the same answer, and distinguishing them would tell a client token holder
773
+ * that owner scope exists to be reached for.
774
+ */
775
+ function resolveOwner(c) {
776
+ const ownerToken = bearer(c);
777
+ if (!ownerToken) {
778
+ return {
779
+ ok: false,
780
+ response: errorResponse(c, 401, "unauthorized", "an owner token is required"),
781
+ };
782
+ }
783
+ const caller = deps.tokens.resolveToken(ownerToken);
784
+ if (!caller.active || caller.tokenKind !== "owner" || !caller.subjectId) {
785
+ return {
786
+ ok: false,
787
+ response: errorResponse(c, 401, "unauthorized", "an active owner token is required"),
788
+ };
789
+ }
790
+ return {
791
+ ok: true,
792
+ subjectId: caller.subjectId,
793
+ ...(caller.sourceId && { sourceId: caller.sourceId }),
794
+ ...(caller.instanceIds && { instanceIds: caller.instanceIds }),
795
+ };
796
+ }
797
+ return app;
798
+ }
799
+ /**
800
+ * The lifecycle value an owner should see for one grant.
801
+ *
802
+ * `grantStatus` answers active/expired/revoked. A redeemed `single_use` grant
803
+ * is none of those in the store's terms — it has not expired and nobody
804
+ * revoked it — yet it can never issue another token, so reporting it as
805
+ * "active" would tell the owner they still have live access they do not have.
806
+ */
807
+ function listedGrantStatus(store, stored, now) {
808
+ const status = store.grantStatus(stored, now);
809
+ if (status === "active" &&
810
+ stored.grant.access_mode === "single_use" &&
811
+ stored.consumedAt) {
812
+ return "consumed";
813
+ }
814
+ return status;
815
+ }
816
+ /**
817
+ * The wire error for one Core approval-failure code.
818
+ *
819
+ * Core keeps `selection_refused` distinct so the two refusal shapes can carry
820
+ * different statuses and different UI handling, but PR #1 names
821
+ * `access_denied` as the OAuth error a client sees for a refused selection.
822
+ * This is the one place those two vocabularies meet, so a client reads
823
+ * standard OAuth while Core keeps its precision.
824
+ */
825
+ function approvalOAuthError(code) {
826
+ if (code === "selection_refused")
827
+ return "access_denied";
828
+ // v0.2: an unsupported recipient term is reported as a structured
829
+ // authorization failure. `invalid_authorization_details` is the RFC 9396
830
+ // code for authorization details the AS will not honour, which is what an
831
+ // uncovered term makes them.
832
+ if (code === "recipient_terms_unsupported") {
833
+ return "invalid_authorization_details";
834
+ }
835
+ return code;
836
+ }
837
+ function approvalStatus(code) {
838
+ switch (code) {
839
+ case "unauthorized":
840
+ return 401;
841
+ case "session_not_found":
842
+ return 404;
843
+ case "stale_review":
844
+ return 409;
845
+ case "recipient_terms_unsupported":
846
+ // 409: the request and the owner's terms conflict, and neither side can
847
+ // fix it alone -- the recipient has to accept the term. Not 403, which
848
+ // would read as "the owner said no".
849
+ return 409;
850
+ case "selection_refused":
851
+ // A refusal, not a malformed request: the client asked for something
852
+ // well-formed and the owner's decision cannot satisfy it. 403 rather
853
+ // than 400 so a consent UI can tell "fix your request" from "this
854
+ // combination cannot be approved". `access_denied` keeps its existing
855
+ // 400 for an already-decided session.
856
+ return 403;
857
+ default:
858
+ return 400;
859
+ }
860
+ }
861
+ function asString(value) {
862
+ return typeof value === "string" && value.length > 0 ? value : null;
863
+ }
864
+ /**
865
+ * Read owner instance picks from repeated `instance[<stream>]=<handle>` query
866
+ * parameters, so a choice survives a plain GET re-fetch without the UI having
867
+ * to hold server state.
868
+ */
869
+ function parseInstanceChoices(c) {
870
+ const choices = {};
871
+ const url = new URL(c.req.url);
872
+ for (const [key, value] of url.searchParams.entries()) {
873
+ const match = /^instance\[(.+)\]$/.exec(key);
874
+ if (!match || value.length === 0)
875
+ continue;
876
+ (choices[match[1]] ??= []).push(value);
877
+ }
878
+ return Object.keys(choices).length > 0 ? choices : undefined;
879
+ }
880
+ /**
881
+ * Read the owner's v0.2 narrowing from the review URL, so a re-fetch shows the
882
+ * current proposal without the UI having to hold server state — the same
883
+ * property `instance[...]` already has.
884
+ *
885
+ * decline[<stream>] — remove an optional stream (value ignored)
886
+ * field[<stream>]=<name> — repeated; the narrowed field list
887
+ * since[<stream>]=<instant> — narrowed window lower bound
888
+ * until[<stream>]=<instant> — narrowed window upper bound
889
+ *
890
+ * A repeated `field[...]` with no values cannot be expressed in a query
891
+ * string, which is why "share nothing from this stream" is `decline[...]`
892
+ * rather than an empty field list. The two are different decisions and the
893
+ * wire keeps them different: declining an optional stream succeeds, while
894
+ * emptying a stream's fields is a refusal.
895
+ */
896
+ /**
897
+ * Read the owner's v0.2 conditions from the review URL.
898
+ *
899
+ * retention_max_duration / retention_on_expiry — proposed retention term
900
+ * condition_purpose_code — proposed purpose code
901
+ *
902
+ * Both retention members are required together: a duration with no expiry
903
+ * action, or the reverse, is half a term, and half a term cannot be matched
904
+ * against what the recipient accepted.
905
+ */
906
+ function parseOwnerConditions(c) {
907
+ const url = new URL(c.req.url);
908
+ const maxDuration = url.searchParams.get("retention_max_duration");
909
+ const onExpiry = url.searchParams.get("retention_on_expiry");
910
+ const purposeCode = url.searchParams.get("condition_purpose_code");
911
+ const conditions = {
912
+ ...(maxDuration &&
913
+ (onExpiry === "delete" || onExpiry === "anonymize") && {
914
+ retention: { max_duration: maxDuration, on_expiry: onExpiry },
915
+ }),
916
+ ...(purposeCode && { purpose_code: purposeCode }),
917
+ };
918
+ return Object.keys(conditions).length > 0 ? conditions : undefined;
919
+ }
920
+ function parseOwnerChoices(c) {
921
+ const declined = [];
922
+ const fields = {};
923
+ const windows = {};
924
+ const url = new URL(c.req.url);
925
+ for (const [key, value] of url.searchParams.entries()) {
926
+ const decline = /^decline\[(.+)\]$/.exec(key);
927
+ if (decline) {
928
+ declined.push(decline[1]);
929
+ continue;
930
+ }
931
+ const field = /^field\[(.+)\]$/.exec(key);
932
+ if (field && value.length > 0) {
933
+ (fields[field[1]] ??= []).push(value);
934
+ continue;
935
+ }
936
+ const since = /^since\[(.+)\]$/.exec(key);
937
+ if (since && value.length > 0) {
938
+ (windows[since[1]] ??= {}).since = value;
939
+ continue;
940
+ }
941
+ const until = /^until\[(.+)\]$/.exec(key);
942
+ if (until && value.length > 0) {
943
+ (windows[until[1]] ??= {}).until = value;
944
+ }
945
+ }
946
+ const choices = {
947
+ ...(declined.length > 0 && { declined_streams: declined }),
948
+ ...(Object.keys(fields).length > 0 && { fields }),
949
+ ...(Object.keys(windows).length > 0 && { time_ranges: windows }),
950
+ };
951
+ return Object.keys(choices).length > 0 ? choices : undefined;
952
+ }
953
+ //# sourceMappingURL=pdpp-auth.js.map