@oaath/server 0.1.0 → 0.3.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 (50) hide show
  1. package/README.md +239 -5
  2. package/dist/apns.d.ts +2 -2
  3. package/dist/apns.js +2 -2
  4. package/dist/{challenge-AGTmUfj2.js → challenge-DmpGSogj.js} +2 -2
  5. package/dist/{challenge-AGTmUfj2.js.map → challenge-DmpGSogj.js.map} +1 -1
  6. package/dist/inbox-B80EUVht.js +249 -0
  7. package/dist/inbox-B80EUVht.js.map +1 -0
  8. package/dist/index.d.ts +9 -255
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +631 -48
  11. package/dist/index.js.map +1 -1
  12. package/dist/interface-DHpwP0wr.d.ts +245 -0
  13. package/dist/interface-DHpwP0wr.d.ts.map +1 -0
  14. package/dist/kernel.d.ts +42 -0
  15. package/dist/kernel.d.ts.map +1 -0
  16. package/dist/kernel.js +80 -0
  17. package/dist/kernel.js.map +1 -0
  18. package/dist/kms-BWkywdJM.d.ts +18 -0
  19. package/dist/kms-BWkywdJM.d.ts.map +1 -0
  20. package/dist/native.d.ts +3 -30
  21. package/dist/native.js +115 -2
  22. package/dist/native.js.map +1 -0
  23. package/dist/postgres.d.ts +38 -3
  24. package/dist/postgres.d.ts.map +1 -1
  25. package/dist/postgres.js +301 -14
  26. package/dist/postgres.js.map +1 -1
  27. package/dist/projection-B7IB9irs.d.ts +228 -0
  28. package/dist/projection-B7IB9irs.d.ts.map +1 -0
  29. package/dist/{records-Bgr-9PUh.js → records-BQNDxbPg.js} +32 -8
  30. package/dist/records-BQNDxbPg.js.map +1 -0
  31. package/dist/records-BUvqmp0D.js +70 -0
  32. package/dist/records-BUvqmp0D.js.map +1 -0
  33. package/dist/service-Cf4f-9KP.js +706 -0
  34. package/dist/service-Cf4f-9KP.js.map +1 -0
  35. package/dist/service-J51bOVe6.d.ts +483 -0
  36. package/dist/service-J51bOVe6.d.ts.map +1 -0
  37. package/package.json +39 -8
  38. package/LICENSE +0 -201
  39. package/dist/authentication-HUYWk5Y2.d.ts +0 -46
  40. package/dist/authentication-HUYWk5Y2.d.ts.map +0 -1
  41. package/dist/decision-dEe9n108.d.ts +0 -53
  42. package/dist/decision-dEe9n108.d.ts.map +0 -1
  43. package/dist/interface-WwvwWs8e.d.ts +0 -114
  44. package/dist/interface-WwvwWs8e.d.ts.map +0 -1
  45. package/dist/native.d.ts.map +0 -1
  46. package/dist/projection-dGIkE0rI.js +0 -516
  47. package/dist/projection-dGIkE0rI.js.map +0 -1
  48. package/dist/projection-g6k_CCUK.d.ts +0 -136
  49. package/dist/projection-g6k_CCUK.d.ts.map +0 -1
  50. package/dist/records-Bgr-9PUh.js.map +0 -1
package/README.md CHANGED
@@ -17,11 +17,116 @@ import { createPostgresRelayStore } from "@oaath/server/postgres";
17
17
  const handler = createRelayHandler({
18
18
  store: createPostgresRelayStore({ connectionString }),
19
19
  authentication, // deployment-owned client/device authentication
20
+ ownerRouting, // resolveOwner(caller, request) returns { ownerDeviceId, ownerSubject } or null
20
21
  kms, // deployment-owned encrypt/decrypt; plaintext never reaches the store
21
22
  clock: { now: () => Date.now() },
22
23
  });
23
24
  ```
24
25
 
26
+ `ownerRouting.resolveOwner(caller, { requestId, requestedScope })` is required.
27
+ The relay captures its route once in the immutable request; null refuses creation.
28
+ The approving phone authenticates as `ownerSubject`, independently of the requesting
29
+ member. Changing the resolver cannot redirect an existing request. Requester
30
+ authentication and grant reference verification retain the member's subject.
31
+
32
+ ## Service directory
33
+
34
+ `createServiceDirectory(store)` owns the records used to select a personal or
35
+ team account. Pass the directory as both `bootstrap` and `ownerRouting`:
36
+
37
+ ```ts
38
+ import { createServiceDirectory } from "@oaath/server";
39
+ import {
40
+ createPostgresServiceDirectorySchema,
41
+ createPostgresServiceDirectoryStore,
42
+ } from "@oaath/server/postgres";
43
+
44
+ // Provision once in a new schema. Existing schemas are not migrated.
45
+ await createPostgresServiceDirectorySchema(pool);
46
+ const directory = createServiceDirectory(
47
+ createPostgresServiceDirectoryStore({ pool }),
48
+ );
49
+ await directory.replace({ expectedRevision: null, directory: initialDirectory });
50
+ const handler = createRelayHandler({ store, authentication, kms, clock, chains,
51
+ bootstrap: directory, ownerRouting: directory });
52
+ ```
53
+
54
+ The `oaath.service-directory/v1` document contains `workspaces`, `applications`,
55
+ `memberships`, `accounts`, `ownerDevices`, and `selections`. Membership uses
56
+ the authenticated `(clientId, subject)` pair; an account references an owner
57
+ device within its workspace. Each account owns its Kernel profile, owner
58
+ validator binding, and configured chain IDs. After authenticating pairing, a
59
+ deployment calls `enrollOwnerDevice({ expectedRevision, device, accounts })` to
60
+ register a phone and its new P-256 Kernel accounts atomically in one workspace.
61
+ All accounts must bind that phone's same public owner key. Existing device or
62
+ account identities cannot be overwritten; enrollment does not add membership.
63
+ The deployment assigns the authenticated owner subject and separately issues
64
+ relay credentials. The directory stores public account identity and routing,
65
+ not private keys or bearer credentials.
66
+
67
+ `resolveOwner(caller, request)` admits a canonical permission request only when
68
+ its explicit workspace/account context, full account profile, and application
69
+ match a current membership and registered account. It returns that account's
70
+ owner-device route. Account selection is a preference, so changing it does not
71
+ retarget requests from an existing connection. Membership removal refuses new
72
+ requests; previously admitted requests retain their stored route.
73
+
74
+ `read()` returns `{ revision, directory }` or `null`. Deployment administration
75
+ replaces the document using that revision; `replace()` returns `false` if a
76
+ concurrent writer won. `selectAccount(caller, { workspaceId, accountId })`
77
+ checks membership and changes only that caller's selection, also returning
78
+ `false` on a concurrent write. The deployment authenticates the caller before
79
+ invoking this capability; it is not an open HTTP administration API.
80
+
81
+ PostgreSQL stores one atomic document for the PoC. Each resolver reads current
82
+ durable state; there is no process cache. An ambiguous write throws
83
+ `relay_state_ambiguous` and is never retried automatically. The deployment owns
84
+ pool shutdown. Membership/account removal can leave old selections behind;
85
+ resolution checks current records and returns `null` rather than granting
86
+ access through stale preferences. It does not revoke grants or delete operations.
87
+
88
+ ### Phone relay credentials
89
+
90
+ `createOwnerDeviceAuthentication` from `@oaath/server/native` provides the phone
91
+ part of the deployment's authentication port. Compose it with application
92
+ identity authentication; it only returns owner callers.
93
+
94
+ ```ts
95
+ import { createOwnerDeviceAuthentication } from "@oaath/server/native";
96
+ import {
97
+ createPostgresOwnerDeviceCredentialSchema,
98
+ createPostgresOwnerDeviceCredentialStore,
99
+ } from "@oaath/server/postgres";
100
+
101
+ // Provision once, separately from the public directory and relay tables.
102
+ await createPostgresOwnerDeviceCredentialSchema(pool);
103
+ const phoneAuthentication = createOwnerDeviceAuthentication({
104
+ directory,
105
+ store: createPostgresOwnerDeviceCredentialStore({ pool }),
106
+ });
107
+ // Administration only: authenticate pairing and enroll this device first.
108
+ const deviceCredential = await phoneAuthentication.issue({ workspaceId, ownerDeviceId });
109
+ // Return it once to the paired phone; never log it.
110
+ // Route phone requests through phoneAuthentication.authenticate(request).
111
+ ```
112
+
113
+ The store retains only a SHA-256 hash and the workspace/device/subject binding
114
+ under `oaath.owner-device-credential/v1`. The phone keeps the original 32-byte
115
+ base64url bearer. Recreating the service and PostgreSQL pools preserves access
116
+ with that bearer; authentication checks the current directory without a cache.
117
+ Missing devices, changed subjects and explicitly revoked credentials cannot
118
+ authenticate. Unreadable storage is an error.
119
+
120
+ `phoneAuthentication.revoke({ workspaceId, ownerDeviceId })` terminally revokes
121
+ all credentials already issued for that device. It does not revoke grants,
122
+ uninstall permissions, remove enrollment or erase phone keys. A later explicit
123
+ `issue` creates another credential; old revoked credentials remain unusable.
124
+ Issuance and revocation never retry writes. A lost issuance response throws and
125
+ returns no credential; the deployment owns pairing delivery and pool shutdown.
126
+ This is credential persistence, not a durable pairing-invitation or full-service
127
+ restart implementation. The demo uses `createMemoryOwnerDeviceCredentialStore`
128
+ and remains ephemeral with its local chains.
129
+
25
130
  ## Endpoints
26
131
 
27
132
  ```text
@@ -31,20 +136,149 @@ POST /authorization/requests/{requestId}/decision owner approve or reject
31
136
  POST /authorization/codes/consume client one-time code consume
32
137
  POST /authorization/artifacts/{artifactId}/claim client one-time artifact claim
33
138
  POST /authorization/resume client fresh auth + recovery read
139
+ POST /grants/verify client grant reference verification
140
+ POST /grants/{grantId}/revocations/{chainId} client request or recover phone custody
141
+ GET /grants/{grantId}/revocations/{chainId} client read current phone custody status
34
142
  ```
35
143
 
36
- EXPERIMENTAL PREVIEW routes (owner-phone approval; wire shapes pinned by the
37
- strict Swift decoders in `native/ios/Sources/OwnerPhone/`):
144
+ EXPERIMENTAL PREVIEW routes (owner-phone approval):
38
145
 
39
146
  ```text
147
+ GET /native/inbox owner pending consent summaries
40
148
  GET /native/projections/{operationId} owner consent projection
149
+ GET /native/permission-signing/{operationId} owner prepared Kernel signing projection
41
150
  POST /native/decisions/{operationId} owner approve or reject saga
151
+ POST /native/revocation-decisions/{operationId} owner revocation custody decision
42
152
  ```
43
153
 
154
+ `GET /native/inbox` returns `oaath.native-inbox/v1` with `requests`, each containing
155
+ only `operationId`, `displayPayload`, and `expiresAt` (epoch milliseconds).
156
+ It reads existing permission and revocation requests for the authenticated owner,
157
+ excluding expired or decided requests, and returns at most 20 ordered by expiry
158
+ then operation ID. There is no separate inbox table or delivery flag to restore.
159
+ PostgreSQL readers recover the list after service recreation. Listing does not
160
+ prepare, sign, submit, or open an approval artifact; fetching consent checks
161
+ current state again. The example phone uses this endpoint for its pull inbox.
162
+
163
+ Canonical phone permission approval requires `RelayHandlerOptions.permissionApprovals`.
164
+ Wire its `prepare(request)` to `prepareKernelPermissionApproval` from
165
+ `@oaath/sdk/kernel`, supplying the deployment's account reads, chain ID, and
166
+ stable install nonce for that request. Return the helper's `signingRequest`
167
+ and a `complete(artifact, decidedAt)` that JSON-serializes its completion result.
168
+ The approval handler holds no owner key. The phone reviews the permission,
169
+ fetches its signing projection, and submits its P-256 artifact to the native
170
+ decision route. That route completes the grant through the injected helper and
171
+ the existing one-time decision transaction. A committed retry returns the stored
172
+ outcome before invoking preparation. An unconfigured deployment cannot approve
173
+ canonical permissions through the native route.
174
+
175
+ Service-approved `grant.revoke()` posts an empty object to the grant/chain revocation
176
+ route for each target without complete chain evidence. Configure
177
+ `RelayHandlerOptions.revocations` with the service `directory` and a
178
+ `prepare({ request, artifact, chainId })` capability. The existing
179
+ `requestOwnerPhoneRevocation` entry from `@oaath/server/native` owns admission
180
+ and durable custody. It checks the original application/member, resolves the
181
+ configured account and phone, and opens the retained approval even after claim
182
+ or execution-policy expiry. The deployment selects the effect, root nonce and
183
+ gas from chain state and passes them, with the request, to
184
+ `prepareKernelPermissionRevocation` from `@oaath/sdk/kernel`, returning its
185
+ `signingRequest`. Preparation must not reserve
186
+ a lane or nonce, sign, or submit; concurrent preparations may lose admission.
187
+
188
+ POST returns `201` for new custody or `200` for recovered custody. GET creates
189
+ nothing. Both return only `{ grantId, chainId, operationId, expiresAt, status }`,
190
+ where status is `pending`, `approved`, `rejected`, or `expired`. Pending and
191
+ terminal requests are recovered before preparation or KMS access, including
192
+ after a lost response. An explicit POST may replace expired, undecided custody;
193
+ approved and rejected requests remain terminal. No uncertain submission or
194
+ expired approval authorizes a replacement operation.
195
+
196
+ The paired phone fetches the shared projection route and posts approve/reject
197
+ to `revocation-decisions`. PostgreSQL preserves the exact request and sealed
198
+ phone artifact across restart. A repeated decision answers the stored outcome,
199
+ even after expiry or a conflicting command. Approval acknowledges custody;
200
+ the operation journal owns submission and finality. The client remains
201
+ `revoking` until every saved target has finalized chain-effect evidence. The
202
+ deployment still owns phone delivery and the execution worker. No OAuth code
203
+ or artifact is released by revocation.
204
+
44
205
  Failures are `{"error":{"code":"relay_*"}}` with the status from
45
206
  `RELAY_ERROR_STATUS`. A response never carries message text, provider output, or
46
207
  internal detail.
47
208
 
209
+ ## Owner revocation execution
210
+
211
+ `@oaath/server/kernel` composes approved phone custody with the SDK's existing
212
+ OperationRunner, OperationStore and OperationObserver. Use
213
+ `createOwnerPhoneRevocationExecutor({ store, kms, clock, operationId, operations,
214
+ observation, submission })` in a deployment worker. `operations` is an SDK
215
+ OperationStoreAdapter; `createPostgresOperationSchema` and
216
+ `createPostgresOperationStoreAdapter({ pool })` from `@oaath/server/postgres`
217
+ provide its durable implementation. Create the current operation tables once
218
+ alongside the relay schema. The deployment owns the pool.
219
+
220
+ `start(timeoutMs)` commits submission evidence before opening the deployment's
221
+ `submission.openSubmission(prepared, signature)` capability. Opening returns
222
+ `{ submit(), close() }` and must not send; the zero-argument submit sends only that
223
+ snapshot. `observe(timeoutMs)` uses the existing observer's chain evidence.
224
+ Recreating the executor preserves factory bytes, nonce, gas, calls and hash.
225
+ An attempted or terminal operation is never submitted again; recovery works
226
+ without KMS or submission access. Expired consent prevents a fresh attempt but
227
+ never hides existing operation evidence. `close()` releases runner resources
228
+ and can be retried after cleanup failure.
229
+
230
+ Finalized operation success is evidence about that exact operation. The client separately observes permission absence and consumed install nonces
231
+ on every saved revocation target. Automatic worker scheduling and replacement
232
+ planning remain separate work. This subpath introduces the server's explicit
233
+ SDK dependency; the relay root imports no runtime or PostgreSQL driver.
234
+
235
+ ## Grant reference verification
236
+
237
+ `POST /grants/verify` lets an integrating application bind an immutable
238
+ artifact of its own (for example a reviewed deployment run) to the exact
239
+ authority revision that approved a Grant. The body is a
240
+ `VerifyGrantRevisionInput` and the `200` response is a
241
+ `GrantVerificationResult`, both owned by `@oaath/protocol`
242
+ (`parseGrantVerificationResult` must parse every response before it is acted
243
+ on). Every field in the body is an assertion compared against the relay's
244
+ durable authorization evidence — never trusted as identity — and the result is
245
+ `authorized` (with the immutable `OaathGrantRef`), `denied`, or `unknown`,
246
+ each with a typed code. Unreadable or absent evidence answers `unknown` and
247
+ never authorizes. Verification is a pure read: it is replay-safe and mutates
248
+ nothing.
249
+
250
+ How an application organization/audience maps to the OAAth client/realm:
251
+
252
+ - One deployed relay URL can serve multiple personal and team workspaces.
253
+ `bootstrap.resolve(caller)` selects the caller's workspace, logical account,
254
+ and configured chains on each request. The SDK keeps local realms separate
255
+ by caller, workspace/account context, and complete account profile. The service
256
+ directory provides the versioned membership and account selection records.
257
+ - `clientId`, the pairwise `subject`, and the `organizationAudience` are all
258
+ asserted by the deployment's `RelayAuthentication` port. An application
259
+ backend with its own cookie session obtains an authenticated OAAth caller by
260
+ implementing that port — its handler reads its own session and returns the
261
+ `RelayCaller` bindings — so no session policy is ever copied out of the
262
+ deployment.
263
+ - The audience is captured onto the authorization request when the Grant is
264
+ requested. Verification denies any audience assertion that does not match
265
+ the captured value; a deployment that declares no audiences therefore never
266
+ verifies one.
267
+ - `revision` is the approved authority revision. OAAth Grant authority is
268
+ immutable per Grant (an authority change is a revocation plus a new Grant),
269
+ so the single approval is revision `1`; anything else denies.
270
+ - `requiredCallsDigest` is `hashGrantPolicyCalls` over the reviewed call set
271
+ and must equal the sealed approval's exact approved call set. `policyDigest`
272
+ is `hashGrantPolicy(approvedPolicy)`, which may differ from the requested
273
+ policy in the Grant identity. The approved expiry also bounds verification.
274
+ - Verification reads the retained encrypted artifact by request ID and checks
275
+ its permission decision's request binding and policy attenuation. An approved
276
+ OAuth outcome alone is insufficient; missing or unreadable approval evidence,
277
+ including unavailable KMS, returns `unknown/grant_unreadable`. Reading never
278
+ consumes a code or claims/releases an artifact, including after client claim
279
+ and process restart. This verifies service-approved authority; it does not
280
+ prove onchain installation or replace the Kernel runtime's capability checks.
281
+
48
282
  ## Security notes
49
283
 
50
284
  - The approving decision, the authorization code, and the encrypted artifact are
@@ -66,15 +300,15 @@ internal detail.
66
300
  ## Schema
67
301
 
68
302
  `createPostgresRelaySchema` creates the one current schema
69
- (`oaath.relay-postgres-schema/v1`). There is no migration runner: an obsolete
303
+ (`oaath.relay-postgres-schema/v4`). There is no migration runner: an obsolete
70
304
  database is dropped and recreated.
71
305
 
72
306
  ## Tests
73
307
 
74
- Ordinary local `pnpm test` never contacts a database. The default repository CI
308
+ Ordinary local `bun run test` never contacts a database. The default repository CI
75
309
  runs the PostgreSQL and restart proofs against its job-local service. Reproduce
76
310
  that gate locally with an explicit opt-in:
77
311
 
78
312
  ```sh
79
- OAATH_REQUIRE_POSTGRES=1 OAATH_POSTGRES_URL=postgres://localhost:5432/postgres pnpm test:postgres
313
+ OAATH_REQUIRE_POSTGRES=1 OAATH_POSTGRES_URL=postgres://localhost:5432/postgres bun run test:postgres
80
314
  ```
package/dist/apns.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { i as RelayClock } from "./authentication-HUYWk5Y2.js";
2
- import { r as OwnerPhonePushProjection } from "./projection-g6k_CCUK.js";
1
+ import { b as RelayClock } from "./interface-DHpwP0wr.js";
2
+ import { n as OwnerPhonePushProjection } from "./projection-B7IB9irs.js";
3
3
  //#region src/apns/sender.d.ts
4
4
  /** Apple's hard limit for an alert notification payload. */
5
5
  declare const APNS_PAYLOAD_MAX_BYTES = 4096;
package/dist/apns.js CHANGED
@@ -1,5 +1,5 @@
1
- import { b as relayFailure, c as canonicalIdentifier, m as timestamp, s as boundedText } from "./records-Bgr-9PUh.js";
2
- import { a as relayNow, n as randomIdentifier } from "./challenge-AGTmUfj2.js";
1
+ import { b as relayFailure, c as canonicalIdentifier, m as timestamp, s as boundedText } from "./records-BQNDxbPg.js";
2
+ import { a as relayNow, n as randomIdentifier } from "./challenge-DmpGSogj.js";
3
3
  import { exactRecord } from "@oaath/protocol";
4
4
  import { createPrivateKey, sign } from "node:crypto";
5
5
  //#region src/apns/sender.ts
@@ -1,4 +1,4 @@
1
- import { b as relayFailure, m as timestamp } from "./records-Bgr-9PUh.js";
1
+ import { b as relayFailure, m as timestamp } from "./records-BQNDxbPg.js";
2
2
  //#region src/clock.ts
3
3
  /**
4
4
  * Injected clock. Expiry is never read from `Date.now()` directly, so tests and
@@ -68,4 +68,4 @@ function isCodeChallengeS256(value) {
68
68
  //#endregion
69
69
  export { relayNow as a, verifyPkceS256 as i, randomIdentifier as n, sha256Base64Url as r, isCodeChallengeS256 as t };
70
70
 
71
- //# sourceMappingURL=challenge-AGTmUfj2.js.map
71
+ //# sourceMappingURL=challenge-DmpGSogj.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"challenge-AGTmUfj2.js","names":[],"sources":["../src/clock.ts","../src/authorization/challenge.ts"],"sourcesContent":["/**\n * Injected clock. Expiry is never read from `Date.now()` directly, so tests and\n * deployments own time.\n *\n * @author taek <leekt216@gmail.com>\n */\n\nimport { relayFailure } from \"./relay/errors.js\";\nimport { timestamp } from \"./store/records.js\";\n\nexport interface RelayClock {\n /** Milliseconds since the Unix epoch as a non-negative safe integer. */\n now(): number;\n}\n\nexport function relayNow(clock: RelayClock): number {\n let value: unknown;\n try {\n value = clock.now();\n } catch {\n return relayFailure(\"relay_internal\", \"injected clock failed\");\n }\n return timestamp(value, \"clock time\", \"relay_internal\");\n}\n","/**\n * PKCE S256 challenge verification and the relay's random/digest primitives.\n *\n * Only WebCrypto and base64url are used, so this file stays platform-neutral.\n * The verifier is never stored: the store holds the S256 challenge, and consume\n * recomputes it.\n *\n * @author taek <leekt216@gmail.com>\n */\n\nimport { relayFailure } from \"../relay/errors.js\";\n\n/** RFC 7636 code verifier: 43-128 unreserved characters. */\nconst CODE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/u;\n\nconst IDENTIFIER_BYTES = 32;\n\nfunction base64Url(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replaceAll(\"+\", \"-\").replaceAll(\"/\", \"_\").replaceAll(\"=\", \"\");\n}\n\n/** 256 bits of CSPRNG output, base64url encoded. */\nexport function randomIdentifier(): string {\n return base64Url(crypto.getRandomValues(new Uint8Array(IDENTIFIER_BYTES)));\n}\n\nexport async function sha256Base64Url(value: string): Promise<string> {\n let digest: ArrayBuffer;\n try {\n digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(value));\n } catch {\n return relayFailure(\"relay_internal\", \"WebCrypto SHA-256 is unavailable\");\n }\n return base64Url(new Uint8Array(digest));\n}\n\nfunction timingSafeEqualText(left: string, right: string): boolean {\n // Both operands are base64url digests, so length is not itself a secret.\n if (left.length !== right.length) return false;\n let difference = 0;\n for (let index = 0; index < left.length; index += 1) {\n difference |= left.charCodeAt(index) ^ right.charCodeAt(index);\n }\n return difference === 0;\n}\n\n/**\n * True only when `codeVerifier` is a well-formed RFC 7636 verifier whose S256\n * digest equals the stored challenge. Any malformed input is a mismatch.\n */\nexport async function verifyPkceS256(\n codeVerifier: string,\n storedCodeChallenge: string,\n): Promise<boolean> {\n if (!CODE_VERIFIER.test(codeVerifier)) return false;\n return timingSafeEqualText(await sha256Base64Url(codeVerifier), storedCodeChallenge);\n}\n\n/** True when the value can be a stored S256 challenge (43 base64url characters). */\nexport function isCodeChallengeS256(value: string): boolean {\n return /^[A-Za-z0-9_-]{43}$/u.test(value);\n}\n"],"mappings":";;;;;;;;AAeA,SAAgB,SAAS,OAA2B;CAClD,IAAI;CACJ,IAAI;EACF,QAAQ,MAAM,IAAI;CACpB,QAAQ;EACN,OAAO,aAAa,kBAAkB,uBAAuB;CAC/D;CACA,OAAO,UAAU,OAAO,cAAc,gBAAgB;AACxD;;;;;;;;;;;;;ACVA,MAAM,gBAAgB;AAEtB,MAAM,mBAAmB;AAEzB,SAAS,UAAU,OAA2B;CAC5C,IAAI,SAAS;CACb,KAAK,MAAM,QAAQ,OAAO,UAAU,OAAO,aAAa,IAAI;CAC5D,OAAO,KAAK,MAAM,CAAC,CAAC,WAAW,KAAK,GAAG,CAAC,CAAC,WAAW,KAAK,GAAG,CAAC,CAAC,WAAW,KAAK,EAAE;AAClF;;AAGA,SAAgB,mBAA2B;CACzC,OAAO,UAAU,OAAO,gBAAgB,IAAI,WAAW,gBAAgB,CAAC,CAAC;AAC3E;AAEA,eAAsB,gBAAgB,OAAgC;CACpE,IAAI;CACJ,IAAI;EACF,SAAS,MAAM,OAAO,OAAO,OAAO,WAAW,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,CAAC;CAChF,QAAQ;EACN,OAAO,aAAa,kBAAkB,kCAAkC;CAC1E;CACA,OAAO,UAAU,IAAI,WAAW,MAAM,CAAC;AACzC;AAEA,SAAS,oBAAoB,MAAc,OAAwB;CAEjE,IAAI,KAAK,WAAW,MAAM,QAAQ,OAAO;CACzC,IAAI,aAAa;CACjB,KAAK,IAAI,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS,GAChD,cAAc,KAAK,WAAW,KAAK,IAAI,MAAM,WAAW,KAAK;CAE/D,OAAO,eAAe;AACxB;;;;;AAMA,eAAsB,eACpB,cACA,qBACkB;CAClB,IAAI,CAAC,cAAc,KAAK,YAAY,GAAG,OAAO;CAC9C,OAAO,oBAAoB,MAAM,gBAAgB,YAAY,GAAG,mBAAmB;AACrF;;AAGA,SAAgB,oBAAoB,OAAwB;CAC1D,OAAO,uBAAuB,KAAK,KAAK;AAC1C"}
1
+ {"version":3,"file":"challenge-DmpGSogj.js","names":[],"sources":["../src/clock.ts","../src/authorization/challenge.ts"],"sourcesContent":["/**\n * Injected clock. Expiry is never read from `Date.now()` directly, so tests and\n * deployments own time.\n *\n * @author taek <leekt216@gmail.com>\n */\n\nimport { relayFailure } from \"./relay/errors.js\";\nimport { timestamp } from \"./store/records.js\";\n\nexport interface RelayClock {\n /** Milliseconds since the Unix epoch as a non-negative safe integer. */\n now(): number;\n}\n\nexport function relayNow(clock: RelayClock): number {\n let value: unknown;\n try {\n value = clock.now();\n } catch {\n return relayFailure(\"relay_internal\", \"injected clock failed\");\n }\n return timestamp(value, \"clock time\", \"relay_internal\");\n}\n","/**\n * PKCE S256 challenge verification and the relay's random/digest primitives.\n *\n * Only WebCrypto and base64url are used, so this file stays platform-neutral.\n * The verifier is never stored: the store holds the S256 challenge, and consume\n * recomputes it.\n *\n * @author taek <leekt216@gmail.com>\n */\n\nimport { relayFailure } from \"../relay/errors.js\";\n\n/** RFC 7636 code verifier: 43-128 unreserved characters. */\nconst CODE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/u;\n\nconst IDENTIFIER_BYTES = 32;\n\nfunction base64Url(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replaceAll(\"+\", \"-\").replaceAll(\"/\", \"_\").replaceAll(\"=\", \"\");\n}\n\n/** 256 bits of CSPRNG output, base64url encoded. */\nexport function randomIdentifier(): string {\n return base64Url(crypto.getRandomValues(new Uint8Array(IDENTIFIER_BYTES)));\n}\n\nexport async function sha256Base64Url(value: string): Promise<string> {\n let digest: ArrayBuffer;\n try {\n digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(value));\n } catch {\n return relayFailure(\"relay_internal\", \"WebCrypto SHA-256 is unavailable\");\n }\n return base64Url(new Uint8Array(digest));\n}\n\nfunction timingSafeEqualText(left: string, right: string): boolean {\n // Both operands are base64url digests, so length is not itself a secret.\n if (left.length !== right.length) return false;\n let difference = 0;\n for (let index = 0; index < left.length; index += 1) {\n difference |= left.charCodeAt(index) ^ right.charCodeAt(index);\n }\n return difference === 0;\n}\n\n/**\n * True only when `codeVerifier` is a well-formed RFC 7636 verifier whose S256\n * digest equals the stored challenge. Any malformed input is a mismatch.\n */\nexport async function verifyPkceS256(\n codeVerifier: string,\n storedCodeChallenge: string,\n): Promise<boolean> {\n if (!CODE_VERIFIER.test(codeVerifier)) return false;\n return timingSafeEqualText(await sha256Base64Url(codeVerifier), storedCodeChallenge);\n}\n\n/** True when the value can be a stored S256 challenge (43 base64url characters). */\nexport function isCodeChallengeS256(value: string): boolean {\n return /^[A-Za-z0-9_-]{43}$/u.test(value);\n}\n"],"mappings":";;;;;;;;AAeA,SAAgB,SAAS,OAA2B;CAClD,IAAI;CACJ,IAAI;EACF,QAAQ,MAAM,IAAI;CACpB,QAAQ;EACN,OAAO,aAAa,kBAAkB,uBAAuB;CAC/D;CACA,OAAO,UAAU,OAAO,cAAc,gBAAgB;AACxD;;;;;;;;;;;;;ACVA,MAAM,gBAAgB;AAEtB,MAAM,mBAAmB;AAEzB,SAAS,UAAU,OAA2B;CAC5C,IAAI,SAAS;CACb,KAAK,MAAM,QAAQ,OAAO,UAAU,OAAO,aAAa,IAAI;CAC5D,OAAO,KAAK,MAAM,CAAC,CAAC,WAAW,KAAK,GAAG,CAAC,CAAC,WAAW,KAAK,GAAG,CAAC,CAAC,WAAW,KAAK,EAAE;AAClF;;AAGA,SAAgB,mBAA2B;CACzC,OAAO,UAAU,OAAO,gBAAgB,IAAI,WAAW,gBAAgB,CAAC,CAAC;AAC3E;AAEA,eAAsB,gBAAgB,OAAgC;CACpE,IAAI;CACJ,IAAI;EACF,SAAS,MAAM,OAAO,OAAO,OAAO,WAAW,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,CAAC;CAChF,QAAQ;EACN,OAAO,aAAa,kBAAkB,kCAAkC;CAC1E;CACA,OAAO,UAAU,IAAI,WAAW,MAAM,CAAC;AACzC;AAEA,SAAS,oBAAoB,MAAc,OAAwB;CAEjE,IAAI,KAAK,WAAW,MAAM,QAAQ,OAAO;CACzC,IAAI,aAAa;CACjB,KAAK,IAAI,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS,GAChD,cAAc,KAAK,WAAW,KAAK,IAAI,MAAM,WAAW,KAAK;CAE/D,OAAO,eAAe;AACxB;;;;;AAMA,eAAsB,eACpB,cACA,qBACkB;CAClB,IAAI,CAAC,cAAc,KAAK,YAAY,GAAG,OAAO;CAC9C,OAAO,oBAAoB,MAAM,gBAAgB,YAAY,GAAG,mBAAmB;AACrF;;AAGA,SAAgB,oBAAoB,OAAwB;CAC1D,OAAO,uBAAuB,KAAK,KAAK;AAC1C"}
@@ -0,0 +1,249 @@
1
+ import { b as relayFailure, h as OaathRelayError, n as OAATH_AUTHORIZATION_DECISION_RECORD_VERSION } from "./records-BQNDxbPg.js";
2
+ import { a as relayNow, n as randomIdentifier, r as sha256Base64Url } from "./challenge-DmpGSogj.js";
3
+ import { S as withRelayTransaction, _ as verifyKernelV4ReplayableInstallOwnerSigningArtifact, d as classifyStoredAuthorizationScope, g as ownerPhoneDisplayPayload, p as fetchAuthorizationRequest, u as prepareOwnerPhonePermissionApproval, v as parseApprovedPermission, x as sealArtifact } from "./service-Cf4f-9KP.js";
4
+ import { parseOwnerSigningArtifact } from "@oaath/protocol";
5
+ //#region src/authorization/decision.ts
6
+ /**
7
+ * Authoritative approve/reject transition.
8
+ *
9
+ * ```text
10
+ * state and owner the decision record owns "decided"; the request record
11
+ * never mutates
12
+ * persisted evidence the immutable requestedScope is reclassified from the
13
+ * durable request before every approval attempt; the
14
+ * artifact must carry its valid bound approval
15
+ * resource occupied? a refused approval occupies nothing and performs no KMS
16
+ * sealing; a rejection occupies only its decision row
17
+ * retry positively safe? refused approval is read-only; a terminal outcome is
18
+ * replayed by the native saga and rejected by this owner
19
+ * transitions permission or verified Kernel owner signing:
20
+ * undecided -> approved | rejected;
21
+ * every reject-only scope: undecided -> rejected once
22
+ * terminal both outcomes; a second decide fails relay_already_decided
23
+ * crash/reload the decision, the code, and the sealed artifact commit in
24
+ * one transaction on the row-locked request, so a crash
25
+ * leaves the request undecided and nothing released
26
+ * cleanup owner the relay transaction; refused approvals allocate nothing
27
+ * ```
28
+ *
29
+ * Owner binding: the approving subject is recovered from the stored
30
+ * authorization request by `requestId` and compared against the authenticated
31
+ * owner. No wire field names the subject. A decision envelope that carries a
32
+ * subject identifier is rejected as an unknown field by exact capture, because a
33
+ * field that only *names* a subject is not cryptographically bound to it and can
34
+ * never decide authority.
35
+ *
36
+ * @author taek <leekt216@gmail.com>
37
+ */
38
+ async function submitAuthorizationDecision(input) {
39
+ const decidedAt = relayNow(input.clock);
40
+ let approvedArtifact;
41
+ if (input.command.outcome === "approved") {
42
+ const state = await fetchAuthorizationRequest({
43
+ store: input.store,
44
+ clock: input.clock,
45
+ caller: input.caller,
46
+ requestId: input.requestId
47
+ });
48
+ if (state.decision !== null) return relayFailure("relay_already_decided", "authorization request is already decided");
49
+ if (state.expired) return relayFailure("relay_expired", "authorization request expired");
50
+ const scope = classifyStoredAuthorizationScope(state.requestedScope, state.requestId);
51
+ if (scope.kind === "permission-request") try {
52
+ approvedArtifact = parseApprovedPermission(input.command.artifact, scope.request, decidedAt).plaintext;
53
+ } catch {
54
+ return relayFailure("relay_request_invalid", "permission approval artifact is invalid");
55
+ }
56
+ else if (scope.kind === "kernel-owner-signing-request") approvedArtifact = verifyKernelV4ReplayableInstallOwnerSigningArtifact(scope.request, input.command.artifact);
57
+ else return relayFailure("relay_request_invalid", "the authorization scope is reject-only");
58
+ }
59
+ let approved;
60
+ if (input.command.outcome === "approved") {
61
+ if (approvedArtifact === void 0) return relayFailure("relay_internal", "approved artifact was not authorized");
62
+ const code = randomIdentifier();
63
+ approved = Object.freeze({
64
+ code,
65
+ artifactId: randomIdentifier(),
66
+ ciphertextRef: await sealArtifact(input.kms, approvedArtifact),
67
+ codeRef: await sealArtifact(input.kms, code)
68
+ });
69
+ }
70
+ const codeHash = approved ? await sha256Base64Url(approved.code) : void 0;
71
+ const decision = Object.freeze({
72
+ version: OAATH_AUTHORIZATION_DECISION_RECORD_VERSION,
73
+ requestId: input.requestId,
74
+ outcome: input.command.outcome,
75
+ decidedAt,
76
+ codeRef: approved?.codeRef ?? null,
77
+ codeExpiresAt: approved ? decidedAt + input.codeTtlMs : null
78
+ });
79
+ const redirectUri = await withRelayTransaction(input.store, async (transaction) => {
80
+ const request = await transaction.lockAuthorizationRequest(input.requestId);
81
+ if (!request || request.ownerSubject !== input.caller.subject) return relayFailure("relay_not_found", "authorization request does not exist");
82
+ if (await transaction.lockAuthorizationDecision(input.requestId)) return relayFailure("relay_already_decided", "authorization request is already decided");
83
+ if (decidedAt >= request.expiresAt) return relayFailure("relay_expired", "authorization request expired");
84
+ if (!await transaction.insertAuthorizationDecision(decision)) return relayFailure("relay_already_decided", "authorization request is already decided");
85
+ if (approved && codeHash !== void 0) {
86
+ if (!(await transaction.insertAuthorizationCode(Object.freeze({
87
+ version: "oaath.authorization-code-record/v1",
88
+ codeHash,
89
+ requestId: request.requestId,
90
+ clientId: request.clientId,
91
+ redirectUri: request.redirectUri,
92
+ codeChallenge: request.codeChallenge,
93
+ artifactId: approved.artifactId,
94
+ createdAt: decidedAt,
95
+ expiresAt: decidedAt + input.codeTtlMs,
96
+ consumedAt: null
97
+ })) && await transaction.insertEncryptedArtifact(Object.freeze({
98
+ version: "oaath.encrypted-artifact-record/v1",
99
+ artifactId: approved.artifactId,
100
+ requestId: request.requestId,
101
+ clientId: request.clientId,
102
+ ciphertextRef: approved.ciphertextRef,
103
+ createdAt: decidedAt,
104
+ claimedAt: null
105
+ })))) return relayFailure("relay_internal", "released identifier is not unique");
106
+ }
107
+ return request.redirectUri;
108
+ });
109
+ if (approved && codeHash !== void 0) return Object.freeze({
110
+ outcome: "approved",
111
+ decidedAt,
112
+ code: approved.code,
113
+ artifactId: approved.artifactId,
114
+ redirectUri,
115
+ codeExpiresAt: decidedAt + input.codeTtlMs
116
+ });
117
+ return Object.freeze({
118
+ outcome: "rejected",
119
+ decidedAt
120
+ });
121
+ }
122
+ //#endregion
123
+ //#region src/native/decision.ts
124
+ /**
125
+ * EXPERIMENTAL PREVIEW — approve/reject saga keyed by the stable operation id.
126
+ *
127
+ * ```text
128
+ * state and owner the relay's authorization decision record. This saga
129
+ * owns no decision state of its own and keeps nothing in
130
+ * memory; it routes to `submitAuthorizationDecision`.
131
+ * persisted evidence one terminal decision row per request
132
+ * resource occupied? yes: the decision occupies its request, and the
133
+ * one-time code and artifact are released exactly once
134
+ * retry positively safe? yes for the saga call, because a repeated call never
135
+ * decides again: it answers the stored outcome
136
+ * transitions undecided -> approved | rejected once, then replay-only
137
+ * crash/reload the outcome is durable before the caller sees it, so a
138
+ * phone that never saw its response retries and is
139
+ * answered from the store, on any process
140
+ * cleanup owner the relay transaction; nothing extra is allocated here
141
+ * ```
142
+ *
143
+ * A second decide answers the *stored* outcome, not the resubmitted command: if
144
+ * a rejected request is retried as approved, the answer is `rejected`. Compare
145
+ * `outcome` against what you sent when that matters.
146
+ *
147
+ * The one-time code and encrypted artifact are released to the deciding call
148
+ * only. A replay reports the outcome and no release material, because releasing
149
+ * it twice is exactly what one-shot semantics forbid.
150
+ *
151
+ * @author taek <leekt216@gmail.com>
152
+ */
153
+ async function submitOwnerPhoneDecision(input) {
154
+ if (input.caller.role !== "owner") return relayFailure("relay_forbidden", "caller may not act in the required role");
155
+ const pending = await fetchAuthorizationRequest({
156
+ ...input,
157
+ requestId: input.operationId
158
+ });
159
+ if (pending.decision !== null) return Object.freeze({
160
+ operationId: input.operationId,
161
+ outcome: pending.decision.outcome,
162
+ decidedAt: pending.decision.decidedAt,
163
+ settlement: "replayed",
164
+ release: null
165
+ });
166
+ let command = input.command;
167
+ if (command.outcome === "approved" && !pending.expired) {
168
+ const scope = classifyStoredAuthorizationScope(pending.requestedScope, pending.requestId);
169
+ if (scope.kind === "permission-request") {
170
+ const prepared = await prepareOwnerPhonePermissionApproval(input.permissionApprovals, scope.request);
171
+ let artifact;
172
+ try {
173
+ artifact = parseOwnerSigningArtifact(JSON.parse(command.artifact));
174
+ } catch {
175
+ return relayFailure("relay_request_invalid", "phone signing artifact is invalid");
176
+ }
177
+ command = {
178
+ outcome: "approved",
179
+ artifact: await prepared.complete(artifact, Math.floor(relayNow(input.clock) / 1e3))
180
+ };
181
+ }
182
+ }
183
+ try {
184
+ const release = await submitAuthorizationDecision({
185
+ store: input.store,
186
+ clock: input.clock,
187
+ kms: input.kms,
188
+ caller: input.caller,
189
+ requestId: input.operationId,
190
+ command,
191
+ codeTtlMs: input.codeTtlMs
192
+ });
193
+ return Object.freeze({
194
+ operationId: input.operationId,
195
+ outcome: release.outcome,
196
+ decidedAt: release.decidedAt,
197
+ settlement: "decided",
198
+ release
199
+ });
200
+ } catch (error) {
201
+ if (!(error instanceof OaathRelayError) || error.code !== "relay_already_decided") throw error;
202
+ }
203
+ const state = await fetchAuthorizationRequest({
204
+ store: input.store,
205
+ clock: input.clock,
206
+ caller: input.caller,
207
+ requestId: input.operationId
208
+ });
209
+ if (state.decision === null) return relayFailure("relay_internal", "decided request has no decision record");
210
+ return Object.freeze({
211
+ operationId: input.operationId,
212
+ outcome: state.decision.outcome,
213
+ decidedAt: state.decision.decidedAt,
214
+ settlement: "replayed",
215
+ release: null
216
+ });
217
+ }
218
+ //#endregion
219
+ //#region src/native/inbox.ts
220
+ /**
221
+ * Read-only owner inbox derived from retained relay requests and decisions.
222
+ * No delivery state, lease or second queue is persisted. Recreating the service
223
+ * reads the same pending requests; deciding or expiring one removes it from the
224
+ * next listing. A summary is discovery only: consent/decision checks current state.
225
+ */
226
+ const OAATH_NATIVE_INBOX_VERSION = "oaath.native-inbox/v1";
227
+ const INBOX_LIMIT = 20;
228
+ /** The store owns transaction resources; this read prepares, signs and submits nothing. */
229
+ async function listOwnerPhoneRequests(input) {
230
+ if (input.caller.role !== "owner") return relayFailure("relay_forbidden", "only an owner may read the inbox");
231
+ const now = relayNow(input.clock);
232
+ const requests = await withRelayTransaction(input.store, (transaction) => transaction.listPendingOwnerRequests(input.caller.subject, now, INBOX_LIMIT));
233
+ if (requests.length > INBOX_LIMIT) return relayFailure("relay_record_unreadable", "owner inbox is unbounded");
234
+ return Object.freeze({
235
+ version: OAATH_NATIVE_INBOX_VERSION,
236
+ requests: Object.freeze(await Promise.all(requests.map(async (request) => {
237
+ if (request.ownerSubject !== input.caller.subject || request.expiresAt <= now) return relayFailure("relay_record_unreadable", "pending request does not match this owner inbox");
238
+ return Object.freeze({
239
+ operationId: request.operationId,
240
+ expiresAt: request.expiresAt,
241
+ displayPayload: await ownerPhoneDisplayPayload(request.ownerSubject, request.operationId)
242
+ });
243
+ })))
244
+ });
245
+ }
246
+ //#endregion
247
+ export { submitAuthorizationDecision as i, listOwnerPhoneRequests as n, submitOwnerPhoneDecision as r, OAATH_NATIVE_INBOX_VERSION as t };
248
+
249
+ //# sourceMappingURL=inbox-B80EUVht.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inbox-B80EUVht.js","names":[],"sources":["../src/authorization/decision.ts","../src/native/decision.ts","../src/native/inbox.ts"],"sourcesContent":["/**\n * Authoritative approve/reject transition.\n *\n * ```text\n * state and owner the decision record owns \"decided\"; the request record\n * never mutates\n * persisted evidence the immutable requestedScope is reclassified from the\n * durable request before every approval attempt; the\n * artifact must carry its valid bound approval\n * resource occupied? a refused approval occupies nothing and performs no KMS\n * sealing; a rejection occupies only its decision row\n * retry positively safe? refused approval is read-only; a terminal outcome is\n * replayed by the native saga and rejected by this owner\n * transitions permission or verified Kernel owner signing:\n * undecided -> approved | rejected;\n * every reject-only scope: undecided -> rejected once\n * terminal both outcomes; a second decide fails relay_already_decided\n * crash/reload the decision, the code, and the sealed artifact commit in\n * one transaction on the row-locked request, so a crash\n * leaves the request undecided and nothing released\n * cleanup owner the relay transaction; refused approvals allocate nothing\n * ```\n *\n * Owner binding: the approving subject is recovered from the stored\n * authorization request by `requestId` and compared against the authenticated\n * owner. No wire field names the subject. A decision envelope that carries a\n * subject identifier is rejected as an unknown field by exact capture, because a\n * field that only *names* a subject is not cryptographically bound to it and can\n * never decide authority.\n *\n * @author taek <leekt216@gmail.com>\n */\n\nimport { sealArtifact } from \"../artifact/encrypt.js\";\nimport { type RelayClock, relayNow } from \"../clock.js\";\nimport { relayFailure } from \"../relay/errors.js\";\nimport type { RelayCaller } from \"../security/authentication.js\";\nimport type { RelayKms } from \"../security/kms.js\";\nimport type { RelayStore } from \"../store/interface.js\";\nimport { withRelayTransaction } from \"../store/interface.js\";\nimport {\n OAATH_AUTHORIZATION_CODE_RECORD_VERSION,\n OAATH_AUTHORIZATION_DECISION_RECORD_VERSION,\n OAATH_ENCRYPTED_ARTIFACT_RECORD_VERSION,\n} from \"../store/records.js\";\nimport { parseApprovedPermission } from \"./approved-permission.js\";\nimport { randomIdentifier, sha256Base64Url } from \"./challenge.js\";\nimport { verifyKernelV4ReplayableInstallOwnerSigningArtifact } from \"./owner-signing.js\";\nimport { fetchAuthorizationRequest } from \"./request.js\";\nimport { classifyStoredAuthorizationScope } from \"./scope.js\";\n\nexport type AuthorizationDecisionCommand =\n /** The owner supplies a request-bound approval artifact; the client claims it once. */\n Readonly<{ outcome: \"approved\"; artifact: string }> | Readonly<{ outcome: \"rejected\" }>;\n\nexport interface SubmitAuthorizationDecisionInput {\n readonly store: RelayStore;\n readonly clock: RelayClock;\n readonly kms: RelayKms;\n /** Authenticated `owner` caller; only the bound subject may decide. */\n readonly caller: RelayCaller;\n readonly requestId: string;\n readonly command: AuthorizationDecisionCommand;\n readonly codeTtlMs: number;\n}\n\nexport type SubmittedAuthorizationDecision =\n | Readonly<{\n outcome: \"approved\";\n decidedAt: number;\n /** Released exactly once, here. Only its SHA-256 is stored. */\n code: string;\n artifactId: string;\n redirectUri: string;\n codeExpiresAt: number;\n }>\n | Readonly<{ outcome: \"rejected\"; decidedAt: number }>;\n\nexport async function submitAuthorizationDecision(\n input: SubmitAuthorizationDecisionInput,\n): Promise<SubmittedAuthorizationDecision> {\n const decidedAt = relayNow(input.clock);\n let approvedArtifact: string | undefined;\n\n // Approval is the artifact-creating transition, so the shared decision owner\n // admits it only for an exact scope this server currently permits to release.\n // Refusal happens before KMS sealing or any durable decision/code/artifact.\n if (input.command.outcome === \"approved\") {\n const state = await fetchAuthorizationRequest({\n store: input.store,\n clock: input.clock,\n caller: input.caller,\n requestId: input.requestId,\n });\n // Preserve the transaction owner's existing precedence: the native saga\n // must recover an earlier terminal outcome without reclassifying its scope.\n if (state.decision !== null) {\n return relayFailure(\"relay_already_decided\", \"authorization request is already decided\");\n }\n if (state.expired) {\n return relayFailure(\"relay_expired\", \"authorization request expired\");\n }\n const scope = classifyStoredAuthorizationScope(state.requestedScope, state.requestId);\n if (scope.kind === \"permission-request\") {\n try {\n approvedArtifact = parseApprovedPermission(\n input.command.artifact,\n scope.request,\n decidedAt,\n ).plaintext;\n } catch {\n return relayFailure(\"relay_request_invalid\", \"permission approval artifact is invalid\");\n }\n } else if (scope.kind === \"kernel-owner-signing-request\") {\n approvedArtifact = verifyKernelV4ReplayableInstallOwnerSigningArtifact(\n scope.request,\n input.command.artifact,\n );\n } else {\n return relayFailure(\"relay_request_invalid\", \"the authorization scope is reject-only\");\n }\n }\n\n // Seal before the transaction: the store only ever receives references, and\n // an uncommitted decision leaves nothing but unreferenced ciphertexts. The\n // code is sealed too so the authenticated client can pick it up later; PKCE\n // still guards its consumption.\n let approved:\n | Readonly<{ code: string; artifactId: string; ciphertextRef: string; codeRef: string }>\n | undefined;\n if (input.command.outcome === \"approved\") {\n if (approvedArtifact === undefined) {\n return relayFailure(\"relay_internal\", \"approved artifact was not authorized\");\n }\n const code = randomIdentifier();\n approved = Object.freeze({\n code,\n artifactId: randomIdentifier(),\n ciphertextRef: await sealArtifact(input.kms, approvedArtifact),\n codeRef: await sealArtifact(input.kms, code),\n });\n }\n const codeHash = approved ? await sha256Base64Url(approved.code) : undefined;\n const decision = Object.freeze({\n version: OAATH_AUTHORIZATION_DECISION_RECORD_VERSION,\n requestId: input.requestId,\n outcome: input.command.outcome,\n decidedAt,\n codeRef: approved?.codeRef ?? null,\n codeExpiresAt: approved ? decidedAt + input.codeTtlMs : null,\n } as const);\n\n const redirectUri = await withRelayTransaction(input.store, async (transaction) => {\n const request = await transaction.lockAuthorizationRequest(input.requestId);\n // The request snapshots its approving subject independently of its requester.\n if (!request || request.ownerSubject !== input.caller.subject) {\n return relayFailure(\"relay_not_found\", \"authorization request does not exist\");\n }\n if (await transaction.lockAuthorizationDecision(input.requestId)) {\n return relayFailure(\"relay_already_decided\", \"authorization request is already decided\");\n }\n if (decidedAt >= request.expiresAt) {\n return relayFailure(\"relay_expired\", \"authorization request expired\");\n }\n if (!(await transaction.insertAuthorizationDecision(decision))) {\n return relayFailure(\"relay_already_decided\", \"authorization request is already decided\");\n }\n if (approved && codeHash !== undefined) {\n const inserted =\n (await transaction.insertAuthorizationCode(\n Object.freeze({\n version: OAATH_AUTHORIZATION_CODE_RECORD_VERSION,\n codeHash,\n requestId: request.requestId,\n clientId: request.clientId,\n redirectUri: request.redirectUri,\n codeChallenge: request.codeChallenge,\n artifactId: approved.artifactId,\n createdAt: decidedAt,\n expiresAt: decidedAt + input.codeTtlMs,\n consumedAt: null,\n }),\n )) &&\n (await transaction.insertEncryptedArtifact(\n Object.freeze({\n version: OAATH_ENCRYPTED_ARTIFACT_RECORD_VERSION,\n artifactId: approved.artifactId,\n requestId: request.requestId,\n clientId: request.clientId,\n ciphertextRef: approved.ciphertextRef,\n createdAt: decidedAt,\n claimedAt: null,\n }),\n ));\n if (!inserted) {\n return relayFailure(\"relay_internal\", \"released identifier is not unique\");\n }\n }\n return request.redirectUri;\n });\n\n if (approved && codeHash !== undefined) {\n return Object.freeze({\n outcome: \"approved\",\n decidedAt,\n code: approved.code,\n artifactId: approved.artifactId,\n redirectUri,\n codeExpiresAt: decidedAt + input.codeTtlMs,\n });\n }\n return Object.freeze({ outcome: \"rejected\", decidedAt });\n}\n","/**\n * EXPERIMENTAL PREVIEW — approve/reject saga keyed by the stable operation id.\n *\n * ```text\n * state and owner the relay's authorization decision record. This saga\n * owns no decision state of its own and keeps nothing in\n * memory; it routes to `submitAuthorizationDecision`.\n * persisted evidence one terminal decision row per request\n * resource occupied? yes: the decision occupies its request, and the\n * one-time code and artifact are released exactly once\n * retry positively safe? yes for the saga call, because a repeated call never\n * decides again: it answers the stored outcome\n * transitions undecided -> approved | rejected once, then replay-only\n * crash/reload the outcome is durable before the caller sees it, so a\n * phone that never saw its response retries and is\n * answered from the store, on any process\n * cleanup owner the relay transaction; nothing extra is allocated here\n * ```\n *\n * A second decide answers the *stored* outcome, not the resubmitted command: if\n * a rejected request is retried as approved, the answer is `rejected`. Compare\n * `outcome` against what you sent when that matters.\n *\n * The one-time code and encrypted artifact are released to the deciding call\n * only. A replay reports the outcome and no release material, because releasing\n * it twice is exactly what one-shot semantics forbid.\n *\n * @author taek <leekt216@gmail.com>\n */\n\nimport { type OwnerSigningArtifact, parseOwnerSigningArtifact } from \"@oaath/protocol\";\nimport type {\n AuthorizationDecisionCommand,\n SubmittedAuthorizationDecision,\n} from \"../authorization/decision.js\";\nimport { submitAuthorizationDecision } from \"../authorization/decision.js\";\nimport { fetchAuthorizationRequest } from \"../authorization/request.js\";\nimport { classifyStoredAuthorizationScope } from \"../authorization/scope.js\";\nimport { type RelayClock, relayNow } from \"../clock.js\";\nimport { OaathRelayError, relayFailure } from \"../relay/errors.js\";\nimport type { RelayCaller } from \"../security/authentication.js\";\nimport type { RelayKms } from \"../security/kms.js\";\nimport type { RelayStore } from \"../store/interface.js\";\nimport type { AuthorizationDecisionOutcome } from \"../store/records.js\";\nimport {\n type OwnerPhonePermissionApprovals,\n prepareOwnerPhonePermissionApproval,\n} from \"./permission-approval.js\";\n\nexport interface SubmitOwnerPhoneDecisionInput {\n readonly store: RelayStore;\n readonly clock: RelayClock;\n readonly kms: RelayKms;\n /** Authenticated `owner` caller; only the bound subject may decide. */\n readonly caller: RelayCaller;\n /** `operationId` from `projectOwnerPhoneRequest`. */\n readonly operationId: string;\n readonly command: AuthorizationDecisionCommand;\n readonly codeTtlMs: number;\n readonly permissionApprovals?: OwnerPhonePermissionApprovals;\n}\n\nexport interface OwnerPhoneDecision {\n readonly operationId: string;\n /** The stored outcome, which on a replay may differ from the command sent. */\n readonly outcome: AuthorizationDecisionOutcome;\n readonly decidedAt: number;\n /** `decided` performed the transition; `replayed` answered the stored one. */\n readonly settlement: \"decided\" | \"replayed\";\n /** One-time release material, on the deciding call only. */\n readonly release: SubmittedAuthorizationDecision | null;\n}\n\nexport async function submitOwnerPhoneDecision(\n input: SubmitOwnerPhoneDecisionInput,\n): Promise<OwnerPhoneDecision> {\n if (input.caller.role !== \"owner\") {\n return relayFailure(\"relay_forbidden\", \"caller may not act in the required role\");\n }\n const pending = await fetchAuthorizationRequest({ ...input, requestId: input.operationId });\n // A committed outcome wins before preparation, even if the provider is now unavailable.\n if (pending.decision !== null)\n return Object.freeze({\n operationId: input.operationId,\n outcome: pending.decision.outcome,\n decidedAt: pending.decision.decidedAt,\n settlement: \"replayed\",\n release: null,\n });\n let command = input.command;\n if (command.outcome === \"approved\" && !pending.expired) {\n const scope = classifyStoredAuthorizationScope(pending.requestedScope, pending.requestId);\n if (scope.kind === \"permission-request\") {\n const prepared = await prepareOwnerPhonePermissionApproval(\n input.permissionApprovals,\n scope.request,\n );\n let artifact: Readonly<OwnerSigningArtifact>;\n try {\n artifact = parseOwnerSigningArtifact(JSON.parse(command.artifact));\n } catch {\n return relayFailure(\"relay_request_invalid\", \"phone signing artifact is invalid\");\n }\n command = {\n outcome: \"approved\",\n artifact: await prepared.complete(artifact, Math.floor(relayNow(input.clock) / 1000)),\n };\n }\n }\n try {\n const release = await submitAuthorizationDecision({\n store: input.store,\n clock: input.clock,\n kms: input.kms,\n caller: input.caller,\n requestId: input.operationId,\n command,\n codeTtlMs: input.codeTtlMs,\n });\n return Object.freeze({\n operationId: input.operationId,\n outcome: release.outcome,\n decidedAt: release.decidedAt,\n settlement: \"decided\",\n release,\n });\n } catch (error) {\n // Only \"already decided\" is idempotent. Ambiguous, expired, unauthorized,\n // and unreadable stay failures: an unproven commit never becomes an answer.\n if (!(error instanceof OaathRelayError) || error.code !== \"relay_already_decided\") throw error;\n }\n const state = await fetchAuthorizationRequest({\n store: input.store,\n clock: input.clock,\n caller: input.caller,\n requestId: input.operationId,\n });\n if (state.decision === null) {\n return relayFailure(\"relay_internal\", \"decided request has no decision record\");\n }\n return Object.freeze({\n operationId: input.operationId,\n outcome: state.decision.outcome,\n decidedAt: state.decision.decidedAt,\n settlement: \"replayed\",\n release: null,\n });\n}\n","/**\n * Read-only owner inbox derived from retained relay requests and decisions.\n * No delivery state, lease or second queue is persisted. Recreating the service\n * reads the same pending requests; deciding or expiring one removes it from the\n * next listing. A summary is discovery only: consent/decision checks current state.\n */\nimport { type RelayClock, relayNow } from \"../clock.js\";\nimport { relayFailure } from \"../relay/errors.js\";\nimport type { RelayCaller } from \"../security/authentication.js\";\nimport { type RelayStore, withRelayTransaction } from \"../store/interface.js\";\nimport { ownerPhoneDisplayPayload } from \"./display.js\";\nimport type { OwnerPhonePushProjection } from \"./projection.js\";\n\nexport const OAATH_NATIVE_INBOX_VERSION = \"oaath.native-inbox/v1\" as const;\nconst INBOX_LIMIT = 20;\n\nexport interface OwnerPhoneInbox {\n readonly version: typeof OAATH_NATIVE_INBOX_VERSION;\n readonly requests: readonly Readonly<OwnerPhonePushProjection>[];\n}\n\n/** The store owns transaction resources; this read prepares, signs and submits nothing. */\nexport async function listOwnerPhoneRequests(input: {\n readonly store: RelayStore;\n readonly clock: RelayClock;\n readonly caller: RelayCaller;\n}): Promise<Readonly<OwnerPhoneInbox>> {\n if (input.caller.role !== \"owner\")\n return relayFailure(\"relay_forbidden\", \"only an owner may read the inbox\");\n const now = relayNow(input.clock);\n const requests = await withRelayTransaction(input.store, (transaction) =>\n transaction.listPendingOwnerRequests(input.caller.subject, now, INBOX_LIMIT),\n );\n if (requests.length > INBOX_LIMIT)\n return relayFailure(\"relay_record_unreadable\", \"owner inbox is unbounded\");\n return Object.freeze({\n version: OAATH_NATIVE_INBOX_VERSION,\n requests: Object.freeze(\n await Promise.all(\n requests.map(async (request) => {\n if (request.ownerSubject !== input.caller.subject || request.expiresAt <= now) {\n return relayFailure(\n \"relay_record_unreadable\",\n \"pending request does not match this owner inbox\",\n );\n }\n return Object.freeze({\n operationId: request.operationId,\n expiresAt: request.expiresAt,\n displayPayload: await ownerPhoneDisplayPayload(\n request.ownerSubject,\n request.operationId,\n ),\n });\n }),\n ),\n ),\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8EA,eAAsB,4BACpB,OACyC;CACzC,MAAM,YAAY,SAAS,MAAM,KAAK;CACtC,IAAI;CAKJ,IAAI,MAAM,QAAQ,YAAY,YAAY;EACxC,MAAM,QAAQ,MAAM,0BAA0B;GAC5C,OAAO,MAAM;GACb,OAAO,MAAM;GACb,QAAQ,MAAM;GACd,WAAW,MAAM;EACnB,CAAC;EAGD,IAAI,MAAM,aAAa,MACrB,OAAO,aAAa,yBAAyB,0CAA0C;EAEzF,IAAI,MAAM,SACR,OAAO,aAAa,iBAAiB,+BAA+B;EAEtE,MAAM,QAAQ,iCAAiC,MAAM,gBAAgB,MAAM,SAAS;EACpF,IAAI,MAAM,SAAS,sBACjB,IAAI;GACF,mBAAmB,wBACjB,MAAM,QAAQ,UACd,MAAM,SACN,SACF,CAAC,CAAC;EACJ,QAAQ;GACN,OAAO,aAAa,yBAAyB,yCAAyC;EACxF;OACK,IAAI,MAAM,SAAS,gCACxB,mBAAmB,oDACjB,MAAM,SACN,MAAM,QAAQ,QAChB;OAEA,OAAO,aAAa,yBAAyB,wCAAwC;CAEzF;CAMA,IAAI;CAGJ,IAAI,MAAM,QAAQ,YAAY,YAAY;EACxC,IAAI,qBAAqB,KAAA,GACvB,OAAO,aAAa,kBAAkB,sCAAsC;EAE9E,MAAM,OAAO,iBAAiB;EAC9B,WAAW,OAAO,OAAO;GACvB;GACA,YAAY,iBAAiB;GAC7B,eAAe,MAAM,aAAa,MAAM,KAAK,gBAAgB;GAC7D,SAAS,MAAM,aAAa,MAAM,KAAK,IAAI;EAC7C,CAAC;CACH;CACA,MAAM,WAAW,WAAW,MAAM,gBAAgB,SAAS,IAAI,IAAI,KAAA;CACnE,MAAM,WAAW,OAAO,OAAO;EAC7B,SAAS;EACT,WAAW,MAAM;EACjB,SAAS,MAAM,QAAQ;EACvB;EACA,SAAS,UAAU,WAAW;EAC9B,eAAe,WAAW,YAAY,MAAM,YAAY;CAC1D,CAAU;CAEV,MAAM,cAAc,MAAM,qBAAqB,MAAM,OAAO,OAAO,gBAAgB;EACjF,MAAM,UAAU,MAAM,YAAY,yBAAyB,MAAM,SAAS;EAE1E,IAAI,CAAC,WAAW,QAAQ,iBAAiB,MAAM,OAAO,SACpD,OAAO,aAAa,mBAAmB,sCAAsC;EAE/E,IAAI,MAAM,YAAY,0BAA0B,MAAM,SAAS,GAC7D,OAAO,aAAa,yBAAyB,0CAA0C;EAEzF,IAAI,aAAa,QAAQ,WACvB,OAAO,aAAa,iBAAiB,+BAA+B;EAEtE,IAAI,CAAE,MAAM,YAAY,4BAA4B,QAAQ,GAC1D,OAAO,aAAa,yBAAyB,0CAA0C;EAEzF,IAAI,YAAY,aAAa,KAAA,GA2BvB;OAAA,EAzBD,MAAM,YAAY,wBACjB,OAAO,OAAO;IACZ,SAAA;IACA;IACA,WAAW,QAAQ;IACnB,UAAU,QAAQ;IAClB,aAAa,QAAQ;IACrB,eAAe,QAAQ;IACvB,YAAY,SAAS;IACrB,WAAW;IACX,WAAW,YAAY,MAAM;IAC7B,YAAY;GACd,CAAC,CACH,KACC,MAAM,YAAY,wBACjB,OAAO,OAAO;IACZ,SAAA;IACA,YAAY,SAAS;IACrB,WAAW,QAAQ;IACnB,UAAU,QAAQ;IAClB,eAAe,SAAS;IACxB,WAAW;IACX,WAAW;GACb,CAAC,CACH,IAEA,OAAO,aAAa,kBAAkB,mCAAmC;EAAA;EAG7E,OAAO,QAAQ;CACjB,CAAC;CAED,IAAI,YAAY,aAAa,KAAA,GAC3B,OAAO,OAAO,OAAO;EACnB,SAAS;EACT;EACA,MAAM,SAAS;EACf,YAAY,SAAS;EACrB;EACA,eAAe,YAAY,MAAM;CACnC,CAAC;CAEH,OAAO,OAAO,OAAO;EAAE,SAAS;EAAY;CAAU,CAAC;AACzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AC3IA,eAAsB,yBACpB,OAC6B;CAC7B,IAAI,MAAM,OAAO,SAAS,SACxB,OAAO,aAAa,mBAAmB,yCAAyC;CAElF,MAAM,UAAU,MAAM,0BAA0B;EAAE,GAAG;EAAO,WAAW,MAAM;CAAY,CAAC;CAE1F,IAAI,QAAQ,aAAa,MACvB,OAAO,OAAO,OAAO;EACnB,aAAa,MAAM;EACnB,SAAS,QAAQ,SAAS;EAC1B,WAAW,QAAQ,SAAS;EAC5B,YAAY;EACZ,SAAS;CACX,CAAC;CACH,IAAI,UAAU,MAAM;CACpB,IAAI,QAAQ,YAAY,cAAc,CAAC,QAAQ,SAAS;EACtD,MAAM,QAAQ,iCAAiC,QAAQ,gBAAgB,QAAQ,SAAS;EACxF,IAAI,MAAM,SAAS,sBAAsB;GACvC,MAAM,WAAW,MAAM,oCACrB,MAAM,qBACN,MAAM,OACR;GACA,IAAI;GACJ,IAAI;IACF,WAAW,0BAA0B,KAAK,MAAM,QAAQ,QAAQ,CAAC;GACnE,QAAQ;IACN,OAAO,aAAa,yBAAyB,mCAAmC;GAClF;GACA,UAAU;IACR,SAAS;IACT,UAAU,MAAM,SAAS,SAAS,UAAU,KAAK,MAAM,SAAS,MAAM,KAAK,IAAI,GAAI,CAAC;GACtF;EACF;CACF;CACA,IAAI;EACF,MAAM,UAAU,MAAM,4BAA4B;GAChD,OAAO,MAAM;GACb,OAAO,MAAM;GACb,KAAK,MAAM;GACX,QAAQ,MAAM;GACd,WAAW,MAAM;GACjB;GACA,WAAW,MAAM;EACnB,CAAC;EACD,OAAO,OAAO,OAAO;GACnB,aAAa,MAAM;GACnB,SAAS,QAAQ;GACjB,WAAW,QAAQ;GACnB,YAAY;GACZ;EACF,CAAC;CACH,SAAS,OAAO;EAGd,IAAI,EAAE,iBAAiB,oBAAoB,MAAM,SAAS,yBAAyB,MAAM;CAC3F;CACA,MAAM,QAAQ,MAAM,0BAA0B;EAC5C,OAAO,MAAM;EACb,OAAO,MAAM;EACb,QAAQ,MAAM;EACd,WAAW,MAAM;CACnB,CAAC;CACD,IAAI,MAAM,aAAa,MACrB,OAAO,aAAa,kBAAkB,wCAAwC;CAEhF,OAAO,OAAO,OAAO;EACnB,aAAa,MAAM;EACnB,SAAS,MAAM,SAAS;EACxB,WAAW,MAAM,SAAS;EAC1B,YAAY;EACZ,SAAS;CACX,CAAC;AACH;;;;;;;;;ACtIA,MAAa,6BAA6B;AAC1C,MAAM,cAAc;;AAQpB,eAAsB,uBAAuB,OAIN;CACrC,IAAI,MAAM,OAAO,SAAS,SACxB,OAAO,aAAa,mBAAmB,kCAAkC;CAC3E,MAAM,MAAM,SAAS,MAAM,KAAK;CAChC,MAAM,WAAW,MAAM,qBAAqB,MAAM,QAAQ,gBACxD,YAAY,yBAAyB,MAAM,OAAO,SAAS,KAAK,WAAW,CAC7E;CACA,IAAI,SAAS,SAAS,aACpB,OAAO,aAAa,2BAA2B,0BAA0B;CAC3E,OAAO,OAAO,OAAO;EACnB,SAAS;EACT,UAAU,OAAO,OACf,MAAM,QAAQ,IACZ,SAAS,IAAI,OAAO,YAAY;GAC9B,IAAI,QAAQ,iBAAiB,MAAM,OAAO,WAAW,QAAQ,aAAa,KACxE,OAAO,aACL,2BACA,iDACF;GAEF,OAAO,OAAO,OAAO;IACnB,aAAa,QAAQ;IACrB,WAAW,QAAQ;IACnB,gBAAgB,MAAM,yBACpB,QAAQ,cACR,QAAQ,WACV;GACF,CAAC;EACH,CAAC,CACH,CACF;CACF,CAAC;AACH"}