@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.
- package/README.md +239 -5
- package/dist/apns.d.ts +2 -2
- package/dist/apns.js +2 -2
- package/dist/{challenge-AGTmUfj2.js → challenge-DmpGSogj.js} +2 -2
- package/dist/{challenge-AGTmUfj2.js.map → challenge-DmpGSogj.js.map} +1 -1
- package/dist/inbox-B80EUVht.js +249 -0
- package/dist/inbox-B80EUVht.js.map +1 -0
- package/dist/index.d.ts +9 -255
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +631 -48
- package/dist/index.js.map +1 -1
- package/dist/interface-DHpwP0wr.d.ts +245 -0
- package/dist/interface-DHpwP0wr.d.ts.map +1 -0
- package/dist/kernel.d.ts +42 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +80 -0
- package/dist/kernel.js.map +1 -0
- package/dist/kms-BWkywdJM.d.ts +18 -0
- package/dist/kms-BWkywdJM.d.ts.map +1 -0
- package/dist/native.d.ts +3 -30
- package/dist/native.js +115 -2
- package/dist/native.js.map +1 -0
- package/dist/postgres.d.ts +38 -3
- package/dist/postgres.d.ts.map +1 -1
- package/dist/postgres.js +301 -14
- package/dist/postgres.js.map +1 -1
- package/dist/projection-B7IB9irs.d.ts +228 -0
- package/dist/projection-B7IB9irs.d.ts.map +1 -0
- package/dist/{records-Bgr-9PUh.js → records-BQNDxbPg.js} +32 -8
- package/dist/records-BQNDxbPg.js.map +1 -0
- package/dist/records-BUvqmp0D.js +70 -0
- package/dist/records-BUvqmp0D.js.map +1 -0
- package/dist/service-Cf4f-9KP.js +706 -0
- package/dist/service-Cf4f-9KP.js.map +1 -0
- package/dist/service-J51bOVe6.d.ts +483 -0
- package/dist/service-J51bOVe6.d.ts.map +1 -0
- package/package.json +39 -8
- package/LICENSE +0 -201
- package/dist/authentication-HUYWk5Y2.d.ts +0 -46
- package/dist/authentication-HUYWk5Y2.d.ts.map +0 -1
- package/dist/decision-dEe9n108.d.ts +0 -53
- package/dist/decision-dEe9n108.d.ts.map +0 -1
- package/dist/interface-WwvwWs8e.d.ts +0 -114
- package/dist/interface-WwvwWs8e.d.ts.map +0 -1
- package/dist/native.d.ts.map +0 -1
- package/dist/projection-dGIkE0rI.js +0 -516
- package/dist/projection-dGIkE0rI.js.map +0 -1
- package/dist/projection-g6k_CCUK.d.ts +0 -136
- package/dist/projection-g6k_CCUK.d.ts.map +0 -1
- 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
|
|
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/
|
|
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 `
|
|
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
|
|
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 {
|
|
2
|
-
import {
|
|
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-
|
|
2
|
-
import { a as relayNow, n as randomIdentifier } from "./challenge-
|
|
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-
|
|
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-
|
|
71
|
+
//# sourceMappingURL=challenge-DmpGSogj.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"challenge-
|
|
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":"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"}
|