@oaath/server 0.1.0
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/LICENSE +201 -0
- package/README.md +80 -0
- package/dist/apns.d.ts +172 -0
- package/dist/apns.d.ts.map +1 -0
- package/dist/apns.js +488 -0
- package/dist/apns.js.map +1 -0
- package/dist/authentication-HUYWk5Y2.d.ts +46 -0
- package/dist/authentication-HUYWk5Y2.d.ts.map +1 -0
- package/dist/challenge-AGTmUfj2.js +71 -0
- package/dist/challenge-AGTmUfj2.js.map +1 -0
- package/dist/decision-dEe9n108.d.ts +53 -0
- package/dist/decision-dEe9n108.d.ts.map +1 -0
- package/dist/index.d.ts +335 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1135 -0
- package/dist/index.js.map +1 -0
- package/dist/interface-WwvwWs8e.d.ts +114 -0
- package/dist/interface-WwvwWs8e.d.ts.map +1 -0
- package/dist/native.d.ts +30 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +2 -0
- package/dist/postgres.d.ts +37 -0
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +401 -0
- package/dist/postgres.js.map +1 -0
- package/dist/projection-dGIkE0rI.js +516 -0
- package/dist/projection-dGIkE0rI.js.map +1 -0
- package/dist/projection-g6k_CCUK.d.ts +136 -0
- package/dist/projection-g6k_CCUK.d.ts.map +1 -0
- package/dist/records-Bgr-9PUh.js +231 -0
- package/dist/records-Bgr-9PUh.js.map +1 -0
- package/package.json +62 -0
package/dist/apns.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
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";
|
|
3
|
+
import { exactRecord } from "@oaath/protocol";
|
|
4
|
+
import { createPrivateKey, sign } from "node:crypto";
|
|
5
|
+
//#region src/apns/sender.ts
|
|
6
|
+
/**
|
|
7
|
+
* EXPERIMENTAL PREVIEW — Apple provider-token signing and APNs payload
|
|
8
|
+
* construction.
|
|
9
|
+
*
|
|
10
|
+
* Preview means: no stability guarantee and no production qualification. Apple
|
|
11
|
+
* provisioning, KMS key rotation, and production operations are later work.
|
|
12
|
+
*
|
|
13
|
+
* Credentials are an injected capability. This package reads no environment
|
|
14
|
+
* variable anywhere in `src/`: a deployment hands over the key material it
|
|
15
|
+
* already owns, and the repository's normal gates scrub `APNS_*`/`APPLE_*` out
|
|
16
|
+
* of the test environment so nothing can drift into an implicit default.
|
|
17
|
+
*
|
|
18
|
+
* The injected capability is a trust boundary, so it is exact-captured once
|
|
19
|
+
* here: unknown fields, a non-P-256 key, or a non-Apple key/team id fail closed
|
|
20
|
+
* with `relay_apns_credentials_invalid`. The provider error is never surfaced,
|
|
21
|
+
* because it can quote key material.
|
|
22
|
+
*
|
|
23
|
+
* Payloads stay opaque. Human text is a phone-side localization key, and the
|
|
24
|
+
* only variable rendered is the projection's bounded match code. No permission,
|
|
25
|
+
* scope, client, chain, or account detail is ever serialized: the payload
|
|
26
|
+
* transits Apple.
|
|
27
|
+
*
|
|
28
|
+
* @author taek <leekt216@gmail.com>
|
|
29
|
+
*/
|
|
30
|
+
/** Apple's hard limit for an alert notification payload. */
|
|
31
|
+
const APNS_PAYLOAD_MAX_BYTES = 4096;
|
|
32
|
+
const OAATH_APNS_PAYLOAD_VERSION = "oaath.apns-payload/v1";
|
|
33
|
+
/** Localization keys: the phone owns every word the owner reads. */
|
|
34
|
+
const APNS_TITLE_LOC_KEY = "oaath_approval_title";
|
|
35
|
+
const APNS_BODY_LOC_KEY = "oaath_approval_body";
|
|
36
|
+
/** Apple rejects a token older than one hour and re-signing more often than 20 minutes. */
|
|
37
|
+
const APNS_TOKEN_MIN_REUSE_MS = 12e5;
|
|
38
|
+
const APNS_TOKEN_MAX_REUSE_MS = 36e5;
|
|
39
|
+
const DEFAULT_REUSE_MS = 27e5;
|
|
40
|
+
const MAX_PEM_LENGTH = 8192;
|
|
41
|
+
/** `apns-collapse-id` is limited to 64 bytes. */
|
|
42
|
+
const MAX_COLLAPSE_ID = 64;
|
|
43
|
+
const APPLE_TEN_CHARACTER_ID = /^[A-Z0-9]{10}$/u;
|
|
44
|
+
const DEVICE_TOKEN = /^[0-9a-fA-F]{64,200}$/u;
|
|
45
|
+
const CREDENTIALS_INVALID = "relay_apns_credentials_invalid";
|
|
46
|
+
const INVALID = "relay_request_invalid";
|
|
47
|
+
function boundedPem(value) {
|
|
48
|
+
if (typeof value !== "string" || value.length < 1 || value.length > MAX_PEM_LENGTH) return relayFailure(CREDENTIALS_INVALID, "privateKeyPem must be a bounded non-empty PEM");
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Bounded, but wide enough that an oversized display payload is a reachable
|
|
53
|
+
* failure instead of an unprovable one: the 4096-byte payload check owns it.
|
|
54
|
+
*/
|
|
55
|
+
function boundedDisplayPayload(value) {
|
|
56
|
+
if (typeof value !== "string" || value.length < 1 || value.length > 4096) return relayFailure(INVALID, "displayPayload must be a bounded non-empty string");
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
function captureCredentials(value) {
|
|
60
|
+
const context = /* @__PURE__ */ new WeakSet();
|
|
61
|
+
const fail = (message) => relayFailure(CREDENTIALS_INVALID, message);
|
|
62
|
+
const record = exactRecord(value, [
|
|
63
|
+
"privateKeyPem",
|
|
64
|
+
"keyId",
|
|
65
|
+
"teamId",
|
|
66
|
+
"topic"
|
|
67
|
+
], "apns credentials", context, fail);
|
|
68
|
+
const keyId = canonicalIdentifier(record.keyId, "keyId", CREDENTIALS_INVALID);
|
|
69
|
+
const teamId = canonicalIdentifier(record.teamId, "teamId", CREDENTIALS_INVALID);
|
|
70
|
+
if (!APPLE_TEN_CHARACTER_ID.test(keyId) || !APPLE_TEN_CHARACTER_ID.test(teamId)) fail("keyId and teamId must be Apple ten-character identifiers");
|
|
71
|
+
return Object.freeze({
|
|
72
|
+
privateKeyPem: boundedPem(record.privateKeyPem),
|
|
73
|
+
keyId,
|
|
74
|
+
teamId,
|
|
75
|
+
topic: canonicalIdentifier(record.topic, "topic", CREDENTIALS_INVALID)
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function signingKey(privateKeyPem) {
|
|
79
|
+
let key;
|
|
80
|
+
try {
|
|
81
|
+
key = createPrivateKey({
|
|
82
|
+
key: privateKeyPem,
|
|
83
|
+
format: "pem"
|
|
84
|
+
});
|
|
85
|
+
} catch {
|
|
86
|
+
return relayFailure(CREDENTIALS_INVALID, "privateKeyPem is not a readable PEM private key");
|
|
87
|
+
}
|
|
88
|
+
if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") return relayFailure(CREDENTIALS_INVALID, "privateKeyPem must be a P-256 signing key");
|
|
89
|
+
return key;
|
|
90
|
+
}
|
|
91
|
+
function reuseWindow(value) {
|
|
92
|
+
if (value === void 0) return DEFAULT_REUSE_MS;
|
|
93
|
+
const milliseconds = timestamp(value, "reuseWindowMs", CREDENTIALS_INVALID);
|
|
94
|
+
if (milliseconds < 12e5 || milliseconds > 36e5) return relayFailure(CREDENTIALS_INVALID, "reuseWindowMs must be 20-60 minutes");
|
|
95
|
+
return milliseconds;
|
|
96
|
+
}
|
|
97
|
+
function base64UrlJson(value) {
|
|
98
|
+
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
|
99
|
+
}
|
|
100
|
+
function captureProjection(value) {
|
|
101
|
+
const context = /* @__PURE__ */ new WeakSet();
|
|
102
|
+
const fail = (message) => relayFailure(INVALID, message);
|
|
103
|
+
const record = exactRecord(value, [
|
|
104
|
+
"operationId",
|
|
105
|
+
"displayPayload",
|
|
106
|
+
"expiresAt"
|
|
107
|
+
], "owner phone projection", context, fail);
|
|
108
|
+
const operationId = canonicalIdentifier(record.operationId, "operationId", INVALID);
|
|
109
|
+
if (operationId.length > MAX_COLLAPSE_ID) fail("operationId must fit an apns-collapse-id");
|
|
110
|
+
return Object.freeze({
|
|
111
|
+
operationId,
|
|
112
|
+
displayPayload: boundedDisplayPayload(record.displayPayload),
|
|
113
|
+
expiresAt: timestamp(record.expiresAt, "expiresAt", INVALID)
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function createApnsSender(input) {
|
|
117
|
+
const credentials = captureCredentials(input.credentials);
|
|
118
|
+
const key = signingKey(credentials.privateKeyPem);
|
|
119
|
+
const windowMs = reuseWindow(input.reuseWindowMs);
|
|
120
|
+
let cached = null;
|
|
121
|
+
const providerToken = () => {
|
|
122
|
+
const now = relayNow(input.clock);
|
|
123
|
+
if (cached !== null && now >= cached.issuedAt && now - cached.issuedAt < windowMs) return cached.token;
|
|
124
|
+
const signingInput = `${base64UrlJson({
|
|
125
|
+
alg: "ES256",
|
|
126
|
+
kid: credentials.keyId,
|
|
127
|
+
typ: "JWT"
|
|
128
|
+
})}.${base64UrlJson({
|
|
129
|
+
iss: credentials.teamId,
|
|
130
|
+
iat: Math.floor(now / 1e3)
|
|
131
|
+
})}`;
|
|
132
|
+
let signature;
|
|
133
|
+
try {
|
|
134
|
+
signature = sign("sha256", Buffer.from(signingInput, "utf8"), {
|
|
135
|
+
key,
|
|
136
|
+
dsaEncoding: "ieee-p1363"
|
|
137
|
+
});
|
|
138
|
+
} catch {
|
|
139
|
+
return relayFailure(CREDENTIALS_INVALID, "provider token could not be signed");
|
|
140
|
+
}
|
|
141
|
+
const token = `${signingInput}.${signature.toString("base64url")}`;
|
|
142
|
+
cached = Object.freeze({
|
|
143
|
+
token,
|
|
144
|
+
issuedAt: now
|
|
145
|
+
});
|
|
146
|
+
return token;
|
|
147
|
+
};
|
|
148
|
+
return Object.freeze({
|
|
149
|
+
providerToken,
|
|
150
|
+
notification(details) {
|
|
151
|
+
const projection = captureProjection(details.projection);
|
|
152
|
+
if (typeof details.deviceToken !== "string" || !DEVICE_TOKEN.test(details.deviceToken)) relayFailure(INVALID, "deviceToken must be a hex APNs device token");
|
|
153
|
+
const payload = JSON.stringify({
|
|
154
|
+
aps: {
|
|
155
|
+
alert: {
|
|
156
|
+
"title-loc-key": APNS_TITLE_LOC_KEY,
|
|
157
|
+
"loc-key": APNS_BODY_LOC_KEY,
|
|
158
|
+
"loc-args": [projection.displayPayload]
|
|
159
|
+
},
|
|
160
|
+
sound: "default"
|
|
161
|
+
},
|
|
162
|
+
oaath: {
|
|
163
|
+
version: OAATH_APNS_PAYLOAD_VERSION,
|
|
164
|
+
operationId: projection.operationId,
|
|
165
|
+
expiresAt: projection.expiresAt
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
if (Buffer.byteLength(payload, "utf8") > 4096) relayFailure("relay_apns_payload_too_large", "apns payload exceeds Apple's limit");
|
|
169
|
+
return Object.freeze({
|
|
170
|
+
operationId: projection.operationId,
|
|
171
|
+
payload,
|
|
172
|
+
headers: Object.freeze({
|
|
173
|
+
":method": "POST",
|
|
174
|
+
":scheme": "https",
|
|
175
|
+
":path": `/3/device/${details.deviceToken}`,
|
|
176
|
+
authorization: `bearer ${providerToken()}`,
|
|
177
|
+
"apns-topic": credentials.topic,
|
|
178
|
+
"apns-push-type": "alert",
|
|
179
|
+
"apns-priority": "10",
|
|
180
|
+
"apns-expiration": String(Math.floor(projection.expiresAt / 1e3)),
|
|
181
|
+
"apns-collapse-id": projection.operationId
|
|
182
|
+
})
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/apns/outbox.ts
|
|
189
|
+
/**
|
|
190
|
+
* EXPERIMENTAL PREVIEW — at-least-once APNs delivery outbox: retry schedule and
|
|
191
|
+
* dead-lettering.
|
|
192
|
+
*
|
|
193
|
+
* ```text
|
|
194
|
+
* state and owner one delivery record per operation id owns "was this
|
|
195
|
+
* push settled?"; the transport owns one attempt only
|
|
196
|
+
* persisted evidence the record: state, attempts, availableAt, lease, and
|
|
197
|
+
* the last classified outcome kind
|
|
198
|
+
* resource occupied? yes: a lease occupies the record until it expires or
|
|
199
|
+
* settles, so two workers never attempt it concurrently
|
|
200
|
+
* retry positively safe? yes, and only here: a push is at-least-once, so a
|
|
201
|
+
* duplicate is collapsed by `apns-collapse-id`, never
|
|
202
|
+
* suppressed by pretending an unproven send succeeded
|
|
203
|
+
* transitions pending -> pending(retrying) | delivered |
|
|
204
|
+
* dead-lettered. Both terminal states are final.
|
|
205
|
+
* crash/reload a crashed worker's lease expires and the record is
|
|
206
|
+
* leasable again; attempts already counted stay counted
|
|
207
|
+
* cleanup owner the lease holder settles; an abandoned lease is
|
|
208
|
+
* reclaimed by expiry, never by another worker's write
|
|
209
|
+
* ```
|
|
210
|
+
*
|
|
211
|
+
* `rejected` dead-letters immediately: Apple refused this device token, and
|
|
212
|
+
* retrying a refusal is how an outbox spins forever. `unreadable` and
|
|
213
|
+
* `unavailable` are retried on the schedule, which is data (`APNS_RETRY_BACKOFF_MS`)
|
|
214
|
+
* so nothing here sleeps or owns a timer.
|
|
215
|
+
*
|
|
216
|
+
* The contract is deliberately its own, not new methods on `RelayTransaction`:
|
|
217
|
+
* a preview surface may not widen the relay's core durable contract, and every
|
|
218
|
+
* store implementation would then owe it. The memory implementation matches the
|
|
219
|
+
* relay store's row-lock semantics; a PostgreSQL table is deferred until the
|
|
220
|
+
* preview is qualified, which is why the schema module is untouched.
|
|
221
|
+
*
|
|
222
|
+
* @author taek <leekt216@gmail.com>
|
|
223
|
+
*/
|
|
224
|
+
const OAATH_APNS_DELIVERY_RECORD_VERSION = "oaath.apns-delivery-record/v1";
|
|
225
|
+
/**
|
|
226
|
+
* Retry delays in milliseconds. One more attempt than there are delays, then the
|
|
227
|
+
* record is dead-lettered.
|
|
228
|
+
*/
|
|
229
|
+
const APNS_RETRY_BACKOFF_MS = Object.freeze([
|
|
230
|
+
1e3,
|
|
231
|
+
5e3,
|
|
232
|
+
3e4
|
|
233
|
+
]);
|
|
234
|
+
const MAX_DEVICE_TOKEN = 200;
|
|
235
|
+
const UNREADABLE = "relay_record_unreadable";
|
|
236
|
+
function deliveryState(value) {
|
|
237
|
+
if (value === "pending" || value === "delivered" || value === "dead-lettered") return value;
|
|
238
|
+
return relayFailure(UNREADABLE, "apns delivery state is unsupported");
|
|
239
|
+
}
|
|
240
|
+
function outcomeKind(value) {
|
|
241
|
+
if (value === null) return null;
|
|
242
|
+
if (value === "delivered" || value === "rejected" || value === "unreadable" || value === "unavailable") return value;
|
|
243
|
+
return relayFailure(UNREADABLE, "apns delivery outcome is unsupported");
|
|
244
|
+
}
|
|
245
|
+
function attempts(value) {
|
|
246
|
+
const count = timestamp(value, "attempts", UNREADABLE);
|
|
247
|
+
if (count > APNS_RETRY_BACKOFF_MS.length + 1) return relayFailure(UNREADABLE, "apns delivery attempts exceed the schedule");
|
|
248
|
+
return count;
|
|
249
|
+
}
|
|
250
|
+
function nullableIdentifier(value, label) {
|
|
251
|
+
return value === null ? null : canonicalIdentifier(value, label, UNREADABLE);
|
|
252
|
+
}
|
|
253
|
+
function nullableTimestamp(value, label) {
|
|
254
|
+
return value === null ? null : timestamp(value, label, UNREADABLE);
|
|
255
|
+
}
|
|
256
|
+
/** Durable storage is a trust boundary, including in memory. */
|
|
257
|
+
function parseApnsDeliveryRecord(value) {
|
|
258
|
+
const record = exactRecord(value, [
|
|
259
|
+
"version",
|
|
260
|
+
"operationId",
|
|
261
|
+
"deviceToken",
|
|
262
|
+
"payload",
|
|
263
|
+
"state",
|
|
264
|
+
"attempts",
|
|
265
|
+
"createdAt",
|
|
266
|
+
"availableAt",
|
|
267
|
+
"leaseId",
|
|
268
|
+
"leaseExpiresAt",
|
|
269
|
+
"settledAt",
|
|
270
|
+
"lastOutcome"
|
|
271
|
+
], "apns delivery record", /* @__PURE__ */ new WeakSet(), (message) => relayFailure(UNREADABLE, message));
|
|
272
|
+
if (record.version !== "oaath.apns-delivery-record/v1") return relayFailure(UNREADABLE, "apns delivery record version is unsupported");
|
|
273
|
+
return Object.freeze({
|
|
274
|
+
version: OAATH_APNS_DELIVERY_RECORD_VERSION,
|
|
275
|
+
operationId: canonicalIdentifier(record.operationId, "operationId", UNREADABLE),
|
|
276
|
+
deviceToken: boundedText(record.deviceToken, MAX_DEVICE_TOKEN, "deviceToken", UNREADABLE),
|
|
277
|
+
payload: boundedText(record.payload, APNS_PAYLOAD_MAX_BYTES, "payload", UNREADABLE),
|
|
278
|
+
state: deliveryState(record.state),
|
|
279
|
+
attempts: attempts(record.attempts),
|
|
280
|
+
createdAt: timestamp(record.createdAt, "createdAt", UNREADABLE),
|
|
281
|
+
availableAt: timestamp(record.availableAt, "availableAt", UNREADABLE),
|
|
282
|
+
leaseId: nullableIdentifier(record.leaseId, "leaseId"),
|
|
283
|
+
leaseExpiresAt: nullableTimestamp(record.leaseExpiresAt, "leaseExpiresAt"),
|
|
284
|
+
settledAt: nullableTimestamp(record.settledAt, "settledAt"),
|
|
285
|
+
lastOutcome: outcomeKind(record.lastOutcome)
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
function settled(record, nextState, kind, now) {
|
|
289
|
+
return Object.freeze({
|
|
290
|
+
...record,
|
|
291
|
+
state: nextState,
|
|
292
|
+
attempts: record.attempts + 1,
|
|
293
|
+
leaseId: null,
|
|
294
|
+
leaseExpiresAt: null,
|
|
295
|
+
settledAt: now,
|
|
296
|
+
lastOutcome: kind
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Process-local outbox with the durable contract's semantics. It survives no
|
|
301
|
+
* restart: use it for local development and tests only.
|
|
302
|
+
*
|
|
303
|
+
* ponytail: a linear scan picks the next due record and one map holds the queue.
|
|
304
|
+
* Both are fine for a preview push queue; index by `availableAt` only if a
|
|
305
|
+
* deployment ever runs enough of them to notice.
|
|
306
|
+
*/
|
|
307
|
+
function createMemoryApnsOutbox(backoffMs = APNS_RETRY_BACKOFF_MS) {
|
|
308
|
+
const rows = /* @__PURE__ */ new Map();
|
|
309
|
+
const load = (operationId) => {
|
|
310
|
+
const stored = rows.get(operationId);
|
|
311
|
+
return stored === void 0 ? void 0 : parseApnsDeliveryRecord(stored);
|
|
312
|
+
};
|
|
313
|
+
return Object.freeze({
|
|
314
|
+
async enqueue(input) {
|
|
315
|
+
const operationId = canonicalIdentifier(input.operationId, "operationId", UNREADABLE);
|
|
316
|
+
if (rows.has(operationId)) return false;
|
|
317
|
+
rows.set(operationId, Object.freeze({
|
|
318
|
+
version: OAATH_APNS_DELIVERY_RECORD_VERSION,
|
|
319
|
+
operationId,
|
|
320
|
+
deviceToken: boundedText(input.deviceToken, MAX_DEVICE_TOKEN, "deviceToken", UNREADABLE),
|
|
321
|
+
payload: boundedText(input.payload, APNS_PAYLOAD_MAX_BYTES, "payload", UNREADABLE),
|
|
322
|
+
state: "pending",
|
|
323
|
+
attempts: 0,
|
|
324
|
+
createdAt: timestamp(input.now, "now", UNREADABLE),
|
|
325
|
+
availableAt: input.now,
|
|
326
|
+
leaseId: null,
|
|
327
|
+
leaseExpiresAt: null,
|
|
328
|
+
settledAt: null,
|
|
329
|
+
lastOutcome: null
|
|
330
|
+
}));
|
|
331
|
+
return true;
|
|
332
|
+
},
|
|
333
|
+
async leaseNext(input) {
|
|
334
|
+
const now = timestamp(input.now, "now", UNREADABLE);
|
|
335
|
+
const leaseMs = timestamp(input.leaseMs, "leaseMs", UNREADABLE);
|
|
336
|
+
for (const key of rows.keys()) {
|
|
337
|
+
const record = load(key);
|
|
338
|
+
if (!record || record.state !== "pending" || record.availableAt > now) continue;
|
|
339
|
+
if (record.leaseExpiresAt !== null && record.leaseExpiresAt > now) continue;
|
|
340
|
+
const leased = Object.freeze({
|
|
341
|
+
...record,
|
|
342
|
+
leaseId: randomIdentifier(),
|
|
343
|
+
leaseExpiresAt: now + leaseMs
|
|
344
|
+
});
|
|
345
|
+
rows.set(key, leased);
|
|
346
|
+
return leased;
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
async settle(input) {
|
|
350
|
+
const now = timestamp(input.now, "now", UNREADABLE);
|
|
351
|
+
const record = load(input.operationId);
|
|
352
|
+
if (!record || record.state !== "pending" || record.leaseId !== input.leaseId) return Object.freeze({ settlement: "stale" });
|
|
353
|
+
if (input.outcome.kind === "delivered") {
|
|
354
|
+
const next = settled(record, "delivered", "delivered", now);
|
|
355
|
+
rows.set(record.operationId, next);
|
|
356
|
+
return Object.freeze({
|
|
357
|
+
settlement: "delivered",
|
|
358
|
+
record: next
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
if (input.outcome.kind === "rejected") {
|
|
362
|
+
const next = settled(record, "dead-lettered", "rejected", now);
|
|
363
|
+
rows.set(record.operationId, next);
|
|
364
|
+
return Object.freeze({
|
|
365
|
+
settlement: "dead-lettered",
|
|
366
|
+
record: next
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
const attempt = record.attempts + 1;
|
|
370
|
+
const delay = backoffMs[attempt - 1];
|
|
371
|
+
if (delay === void 0) {
|
|
372
|
+
const next = settled(record, "dead-lettered", input.outcome.kind, now);
|
|
373
|
+
rows.set(record.operationId, next);
|
|
374
|
+
return Object.freeze({
|
|
375
|
+
settlement: "dead-lettered",
|
|
376
|
+
record: next
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
const next = Object.freeze({
|
|
380
|
+
...record,
|
|
381
|
+
attempts: attempt,
|
|
382
|
+
availableAt: now + delay,
|
|
383
|
+
leaseId: null,
|
|
384
|
+
leaseExpiresAt: null,
|
|
385
|
+
lastOutcome: input.outcome.kind
|
|
386
|
+
});
|
|
387
|
+
rows.set(record.operationId, next);
|
|
388
|
+
return Object.freeze({
|
|
389
|
+
settlement: "retrying",
|
|
390
|
+
record: next
|
|
391
|
+
});
|
|
392
|
+
},
|
|
393
|
+
async read(operationId) {
|
|
394
|
+
return load(operationId);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region src/apns/transport.ts
|
|
400
|
+
/** Bounded refusal body. Apple's is tiny; anything larger is not read. */
|
|
401
|
+
const MAX_BODY_BYTES = 4096;
|
|
402
|
+
/** Apple's documented reasons are short ASCII words. */
|
|
403
|
+
const REASON = /^[A-Za-z]{1,64}$/u;
|
|
404
|
+
function reasonOf(body) {
|
|
405
|
+
let parsed;
|
|
406
|
+
try {
|
|
407
|
+
parsed = JSON.parse(body);
|
|
408
|
+
} catch {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
if (parsed === null || typeof parsed !== "object") return null;
|
|
412
|
+
const reason = parsed.reason;
|
|
413
|
+
return typeof reason === "string" && REASON.test(reason) ? reason : null;
|
|
414
|
+
}
|
|
415
|
+
function classify(status, apnsId, body) {
|
|
416
|
+
if (status === 200) return Object.freeze({
|
|
417
|
+
kind: "delivered",
|
|
418
|
+
apnsId
|
|
419
|
+
});
|
|
420
|
+
if (status === null || status === 429 || status === 500 || status === 503) return Object.freeze({ kind: status === null ? "unreadable" : "unavailable" });
|
|
421
|
+
const reason = reasonOf(body);
|
|
422
|
+
return reason === null ? Object.freeze({ kind: "unreadable" }) : Object.freeze({
|
|
423
|
+
kind: "rejected",
|
|
424
|
+
status,
|
|
425
|
+
reason
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
function headerText(value) {
|
|
429
|
+
return typeof value === "string" ? value : null;
|
|
430
|
+
}
|
|
431
|
+
/** Response headers are provider output: read them as data, never as a shape. */
|
|
432
|
+
function headerMap(headers) {
|
|
433
|
+
if (headers === null || typeof headers !== "object") return /* @__PURE__ */ new Map();
|
|
434
|
+
return new Map(Object.entries(headers));
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Sends one notification and settles exactly once. It never retries, and it
|
|
438
|
+
* never throws: every outcome, including an unopenable stream, is a classified
|
|
439
|
+
* value the outbox decides on.
|
|
440
|
+
*/
|
|
441
|
+
function sendApnsNotification(input) {
|
|
442
|
+
return new Promise((resolve) => {
|
|
443
|
+
let stream;
|
|
444
|
+
try {
|
|
445
|
+
stream = input.session.request(input.notification.headers);
|
|
446
|
+
} catch {
|
|
447
|
+
resolve(Object.freeze({ kind: "unavailable" }));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
let settled = false;
|
|
451
|
+
let status = null;
|
|
452
|
+
let apnsId = null;
|
|
453
|
+
let body = "";
|
|
454
|
+
const settle = (outcome) => {
|
|
455
|
+
if (settled) return;
|
|
456
|
+
settled = true;
|
|
457
|
+
try {
|
|
458
|
+
stream.destroy();
|
|
459
|
+
} catch {}
|
|
460
|
+
resolve(outcome);
|
|
461
|
+
};
|
|
462
|
+
stream.on("response", (headers) => {
|
|
463
|
+
const map = headerMap(headers);
|
|
464
|
+
const raw = map.get(":status");
|
|
465
|
+
status = typeof raw === "number" ? raw : Number.parseInt(headerText(raw) ?? "", 10);
|
|
466
|
+
if (!Number.isInteger(status)) status = null;
|
|
467
|
+
apnsId = headerText(map.get("apns-id"));
|
|
468
|
+
});
|
|
469
|
+
stream.on("data", (chunk) => {
|
|
470
|
+
if (body.length >= MAX_BODY_BYTES) return;
|
|
471
|
+
body += typeof chunk === "string" ? chunk : String(chunk);
|
|
472
|
+
});
|
|
473
|
+
stream.on("end", () => {
|
|
474
|
+
settle(classify(status, apnsId, body.slice(0, MAX_BODY_BYTES)));
|
|
475
|
+
});
|
|
476
|
+
stream.on("error", () => {
|
|
477
|
+
settle(Object.freeze({ kind: status === null ? "unavailable" : "unreadable" }));
|
|
478
|
+
});
|
|
479
|
+
stream.setTimeout(input.timeoutMs, () => {
|
|
480
|
+
settle(Object.freeze({ kind: "unreadable" }));
|
|
481
|
+
});
|
|
482
|
+
stream.end(input.notification.payload);
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
//#endregion
|
|
486
|
+
export { APNS_BODY_LOC_KEY, APNS_PAYLOAD_MAX_BYTES, APNS_RETRY_BACKOFF_MS, APNS_TITLE_LOC_KEY, APNS_TOKEN_MAX_REUSE_MS, APNS_TOKEN_MIN_REUSE_MS, OAATH_APNS_DELIVERY_RECORD_VERSION, OAATH_APNS_PAYLOAD_VERSION, createApnsSender, createMemoryApnsOutbox, parseApnsDeliveryRecord, sendApnsNotification };
|
|
487
|
+
|
|
488
|
+
//# sourceMappingURL=apns.js.map
|
package/dist/apns.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apns.js","names":[],"sources":["../src/apns/sender.ts","../src/apns/outbox.ts","../src/apns/transport.ts"],"sourcesContent":["/**\n * EXPERIMENTAL PREVIEW — Apple provider-token signing and APNs payload\n * construction.\n *\n * Preview means: no stability guarantee and no production qualification. Apple\n * provisioning, KMS key rotation, and production operations are later work.\n *\n * Credentials are an injected capability. This package reads no environment\n * variable anywhere in `src/`: a deployment hands over the key material it\n * already owns, and the repository's normal gates scrub `APNS_*`/`APPLE_*` out\n * of the test environment so nothing can drift into an implicit default.\n *\n * The injected capability is a trust boundary, so it is exact-captured once\n * here: unknown fields, a non-P-256 key, or a non-Apple key/team id fail closed\n * with `relay_apns_credentials_invalid`. The provider error is never surfaced,\n * because it can quote key material.\n *\n * Payloads stay opaque. Human text is a phone-side localization key, and the\n * only variable rendered is the projection's bounded match code. No permission,\n * scope, client, chain, or account detail is ever serialized: the payload\n * transits Apple.\n *\n * @author taek <leekt216@gmail.com>\n */\n\nimport { createPrivateKey, type KeyObject, sign } from \"node:crypto\";\nimport { type CaptureContext, exactRecord } from \"@oaath/protocol\";\nimport { type RelayClock, relayNow } from \"../clock.js\";\nimport type { OwnerPhonePushProjection } from \"../native/projection.js\";\nimport { relayFailure } from \"../relay/errors.js\";\nimport { canonicalIdentifier, timestamp } from \"../store/records.js\";\n\n/** Apple's hard limit for an alert notification payload. */\nexport const APNS_PAYLOAD_MAX_BYTES = 4096;\n\nexport const OAATH_APNS_PAYLOAD_VERSION = \"oaath.apns-payload/v1\" as const;\n\n/** Localization keys: the phone owns every word the owner reads. */\nexport const APNS_TITLE_LOC_KEY = \"oaath_approval_title\" as const;\nexport const APNS_BODY_LOC_KEY = \"oaath_approval_body\" as const;\n\n/** Apple rejects a token older than one hour and re-signing more often than 20 minutes. */\nexport const APNS_TOKEN_MIN_REUSE_MS = 1_200_000;\nexport const APNS_TOKEN_MAX_REUSE_MS = 3_600_000;\nconst DEFAULT_REUSE_MS = 2_700_000;\n\nconst MAX_PEM_LENGTH = 8192;\n/** `apns-collapse-id` is limited to 64 bytes. */\nconst MAX_COLLAPSE_ID = 64;\nconst APPLE_TEN_CHARACTER_ID = /^[A-Z0-9]{10}$/u;\nconst DEVICE_TOKEN = /^[0-9a-fA-F]{64,200}$/u;\n\nconst CREDENTIALS_INVALID = \"relay_apns_credentials_invalid\" as const;\nconst INVALID = \"relay_request_invalid\" as const;\n\nexport interface ApnsCredentials {\n /** PEM-encoded P-256 (`prime256v1`) private key from Apple. */\n readonly privateKeyPem: string;\n /** Apple key id (`kid`). */\n readonly keyId: string;\n /** Apple team id (`iss`). */\n readonly teamId: string;\n /** Bundle identifier used as `apns-topic`. */\n readonly topic: string;\n}\n\nexport interface CreateApnsSenderInput {\n /** Injected capability object. Never sourced from `process.env`. */\n readonly credentials: ApnsCredentials;\n readonly clock: RelayClock;\n /** 20-60 minutes. Defaults to 45. */\n readonly reuseWindowMs?: number;\n}\n\nexport interface ApnsNotificationInput {\n /** Lowercase or uppercase hex device token. */\n readonly deviceToken: string;\n /**\n * Only the opaque push subset is accepted: the payload transits Apple, so\n * the consent projection's client and scope detail must never reach here.\n */\n readonly projection: OwnerPhonePushProjection;\n}\n\nexport interface ApnsNotification {\n readonly operationId: string;\n /** HTTP/2 request headers, including the `:method`/`:scheme`/`:path` pseudo-headers. */\n readonly headers: Readonly<Record<string, string>>;\n /** JSON body, already proven to be within `APNS_PAYLOAD_MAX_BYTES`. */\n readonly payload: string;\n}\n\nexport interface ApnsSender {\n /** Signs lazily and reuses one token for the whole reuse window. */\n providerToken(): string;\n notification(input: ApnsNotificationInput): ApnsNotification;\n}\n\nfunction boundedPem(value: unknown): string {\n if (typeof value !== \"string\" || value.length < 1 || value.length > MAX_PEM_LENGTH) {\n return relayFailure(CREDENTIALS_INVALID, \"privateKeyPem must be a bounded non-empty PEM\");\n }\n return value;\n}\n\n/**\n * Bounded, but wide enough that an oversized display payload is a reachable\n * failure instead of an unprovable one: the 4096-byte payload check owns it.\n */\nfunction boundedDisplayPayload(value: unknown): string {\n if (typeof value !== \"string\" || value.length < 1 || value.length > APNS_PAYLOAD_MAX_BYTES) {\n return relayFailure(INVALID, \"displayPayload must be a bounded non-empty string\");\n }\n return value;\n}\n\nfunction captureCredentials(value: unknown): ApnsCredentials {\n const context: CaptureContext = new WeakSet();\n const fail = (message: string): never => relayFailure(CREDENTIALS_INVALID, message);\n const record = exactRecord(\n value,\n [\"privateKeyPem\", \"keyId\", \"teamId\", \"topic\"],\n \"apns credentials\",\n context,\n fail,\n );\n const keyId = canonicalIdentifier(record.keyId, \"keyId\", CREDENTIALS_INVALID);\n const teamId = canonicalIdentifier(record.teamId, \"teamId\", CREDENTIALS_INVALID);\n if (!APPLE_TEN_CHARACTER_ID.test(keyId) || !APPLE_TEN_CHARACTER_ID.test(teamId)) {\n fail(\"keyId and teamId must be Apple ten-character identifiers\");\n }\n return Object.freeze({\n // PEM carries newlines, so it is bounded by length only and validated by the\n // key parser rather than by the control-character text rules.\n privateKeyPem: boundedPem(record.privateKeyPem),\n keyId,\n teamId,\n // A topic reaches an HTTP/2 header, so it stays URL-safe by construction.\n topic: canonicalIdentifier(record.topic, \"topic\", CREDENTIALS_INVALID),\n });\n}\n\nfunction signingKey(privateKeyPem: string): KeyObject {\n let key: KeyObject;\n try {\n key = createPrivateKey({ key: privateKeyPem, format: \"pem\" });\n } catch {\n // The parser error can quote key material; only the code leaves.\n return relayFailure(CREDENTIALS_INVALID, \"privateKeyPem is not a readable PEM private key\");\n }\n if (key.asymmetricKeyType !== \"ec\" || key.asymmetricKeyDetails?.namedCurve !== \"prime256v1\") {\n return relayFailure(CREDENTIALS_INVALID, \"privateKeyPem must be a P-256 signing key\");\n }\n return key;\n}\n\nfunction reuseWindow(value: number | undefined): number {\n if (value === undefined) return DEFAULT_REUSE_MS;\n const milliseconds = timestamp(value, \"reuseWindowMs\", CREDENTIALS_INVALID);\n if (milliseconds < APNS_TOKEN_MIN_REUSE_MS || milliseconds > APNS_TOKEN_MAX_REUSE_MS) {\n return relayFailure(CREDENTIALS_INVALID, \"reuseWindowMs must be 20-60 minutes\");\n }\n return milliseconds;\n}\n\nfunction base64UrlJson(value: unknown): string {\n return Buffer.from(JSON.stringify(value), \"utf8\").toString(\"base64url\");\n}\n\nfunction captureProjection(value: unknown): OwnerPhonePushProjection {\n const context: CaptureContext = new WeakSet();\n const fail = (message: string): never => relayFailure(INVALID, message);\n const record = exactRecord(\n value,\n [\"operationId\", \"displayPayload\", \"expiresAt\"],\n \"owner phone projection\",\n context,\n fail,\n );\n const operationId = canonicalIdentifier(record.operationId, \"operationId\", INVALID);\n if (operationId.length > MAX_COLLAPSE_ID) {\n fail(\"operationId must fit an apns-collapse-id\");\n }\n return Object.freeze({\n operationId,\n displayPayload: boundedDisplayPayload(record.displayPayload),\n expiresAt: timestamp(record.expiresAt, \"expiresAt\", INVALID),\n });\n}\n\nexport function createApnsSender(input: CreateApnsSenderInput): ApnsSender {\n const credentials = captureCredentials(input.credentials);\n const key = signingKey(credentials.privateKeyPem);\n const windowMs = reuseWindow(input.reuseWindowMs);\n let cached: Readonly<{ token: string; issuedAt: number }> | null = null;\n\n const providerToken = (): string => {\n const now = relayNow(input.clock);\n // A clock that moved backwards invalidates the cache rather than extending it.\n if (cached !== null && now >= cached.issuedAt && now - cached.issuedAt < windowMs) {\n return cached.token;\n }\n const signingInput = `${base64UrlJson({\n alg: \"ES256\",\n kid: credentials.keyId,\n typ: \"JWT\",\n })}.${base64UrlJson({ iss: credentials.teamId, iat: Math.floor(now / 1000) })}`;\n let signature: Buffer;\n try {\n // JOSE needs the raw r||s form, not the DER sequence node signs by default.\n signature = sign(\"sha256\", Buffer.from(signingInput, \"utf8\"), {\n key,\n dsaEncoding: \"ieee-p1363\",\n });\n } catch {\n return relayFailure(CREDENTIALS_INVALID, \"provider token could not be signed\");\n }\n const token = `${signingInput}.${signature.toString(\"base64url\")}`;\n cached = Object.freeze({ token, issuedAt: now });\n return token;\n };\n\n return Object.freeze({\n providerToken,\n notification(details: ApnsNotificationInput): ApnsNotification {\n const projection = captureProjection(details.projection);\n if (typeof details.deviceToken !== \"string\" || !DEVICE_TOKEN.test(details.deviceToken)) {\n // The token reaches the `:path` pseudo-header; only hex may get there.\n relayFailure(INVALID, \"deviceToken must be a hex APNs device token\");\n }\n const payload = JSON.stringify({\n aps: {\n alert: {\n \"title-loc-key\": APNS_TITLE_LOC_KEY,\n \"loc-key\": APNS_BODY_LOC_KEY,\n \"loc-args\": [projection.displayPayload],\n },\n sound: \"default\",\n },\n oaath: {\n version: OAATH_APNS_PAYLOAD_VERSION,\n operationId: projection.operationId,\n expiresAt: projection.expiresAt,\n },\n });\n if (Buffer.byteLength(payload, \"utf8\") > APNS_PAYLOAD_MAX_BYTES) {\n relayFailure(\"relay_apns_payload_too_large\", \"apns payload exceeds Apple's limit\");\n }\n return Object.freeze({\n operationId: projection.operationId,\n payload,\n headers: Object.freeze({\n \":method\": \"POST\",\n \":scheme\": \"https\",\n \":path\": `/3/device/${details.deviceToken}`,\n authorization: `bearer ${providerToken()}`,\n \"apns-topic\": credentials.topic,\n \"apns-push-type\": \"alert\",\n \"apns-priority\": \"10\",\n \"apns-expiration\": String(Math.floor(projection.expiresAt / 1000)),\n // One notification per operation, so a duplicate push collapses.\n \"apns-collapse-id\": projection.operationId,\n }),\n });\n },\n });\n}\n","/**\n * EXPERIMENTAL PREVIEW — at-least-once APNs delivery outbox: retry schedule and\n * dead-lettering.\n *\n * ```text\n * state and owner one delivery record per operation id owns \"was this\n * push settled?\"; the transport owns one attempt only\n * persisted evidence the record: state, attempts, availableAt, lease, and\n * the last classified outcome kind\n * resource occupied? yes: a lease occupies the record until it expires or\n * settles, so two workers never attempt it concurrently\n * retry positively safe? yes, and only here: a push is at-least-once, so a\n * duplicate is collapsed by `apns-collapse-id`, never\n * suppressed by pretending an unproven send succeeded\n * transitions pending -> pending(retrying) | delivered |\n * dead-lettered. Both terminal states are final.\n * crash/reload a crashed worker's lease expires and the record is\n * leasable again; attempts already counted stay counted\n * cleanup owner the lease holder settles; an abandoned lease is\n * reclaimed by expiry, never by another worker's write\n * ```\n *\n * `rejected` dead-letters immediately: Apple refused this device token, and\n * retrying a refusal is how an outbox spins forever. `unreadable` and\n * `unavailable` are retried on the schedule, which is data (`APNS_RETRY_BACKOFF_MS`)\n * so nothing here sleeps or owns a timer.\n *\n * The contract is deliberately its own, not new methods on `RelayTransaction`:\n * a preview surface may not widen the relay's core durable contract, and every\n * store implementation would then owe it. The memory implementation matches the\n * relay store's row-lock semantics; a PostgreSQL table is deferred until the\n * preview is qualified, which is why the schema module is untouched.\n *\n * @author taek <leekt216@gmail.com>\n */\n\nimport { type CaptureContext, exactRecord } from \"@oaath/protocol\";\nimport { randomIdentifier } from \"../authorization/challenge.js\";\nimport { relayFailure } from \"../relay/errors.js\";\nimport { boundedText, canonicalIdentifier, timestamp } from \"../store/records.js\";\nimport { APNS_PAYLOAD_MAX_BYTES } from \"./sender.js\";\nimport type { ApnsDeliveryOutcome } from \"./transport.js\";\n\nexport const OAATH_APNS_DELIVERY_RECORD_VERSION = \"oaath.apns-delivery-record/v1\" as const;\n\n/**\n * Retry delays in milliseconds. One more attempt than there are delays, then the\n * record is dead-lettered.\n */\nexport const APNS_RETRY_BACKOFF_MS: readonly number[] = Object.freeze([1_000, 5_000, 30_000]);\n\nconst MAX_DEVICE_TOKEN = 200;\nconst UNREADABLE = \"relay_record_unreadable\" as const;\n\nexport type ApnsDeliveryState = \"pending\" | \"delivered\" | \"dead-lettered\";\n\nexport interface ApnsDeliveryRecord {\n readonly version: typeof OAATH_APNS_DELIVERY_RECORD_VERSION;\n /** Stable operation id from the phone projection. One delivery per operation. */\n readonly operationId: string;\n readonly deviceToken: string;\n /** The exact bytes to send. Opaque here; the sender built and bounded it. */\n readonly payload: string;\n readonly state: ApnsDeliveryState;\n /** Settled attempts, including the one that reached a terminal state. */\n readonly attempts: number;\n readonly createdAt: number;\n /** Earliest next attempt. Backoff is data; nothing in this module waits. */\n readonly availableAt: number;\n /** Non-null only while leased, and held by exactly one worker. */\n readonly leaseId: string | null;\n readonly leaseExpiresAt: number | null;\n /** Non-null exactly for the two terminal states. */\n readonly settledAt: number | null;\n /** Last classified transport outcome. Never Apple's prose. */\n readonly lastOutcome: ApnsDeliveryOutcome[\"kind\"] | null;\n}\n\nexport type ApnsSettleResult =\n | Readonly<{ settlement: \"delivered\" | \"retrying\" | \"dead-lettered\"; record: ApnsDeliveryRecord }>\n /** The lease is not the current one: a slower worker lost the record. */\n | Readonly<{ settlement: \"stale\" }>;\n\nexport interface ApnsEnqueueInput {\n readonly operationId: string;\n readonly deviceToken: string;\n readonly payload: string;\n readonly now: number;\n}\n\nexport interface ApnsLeaseInput {\n readonly now: number;\n readonly leaseMs: number;\n}\n\nexport interface ApnsSettleInput {\n readonly operationId: string;\n readonly leaseId: string;\n readonly outcome: ApnsDeliveryOutcome;\n readonly now: number;\n}\n\nexport interface ApnsOutbox {\n /** False when the operation is already enqueued. One push per operation. */\n enqueue(input: ApnsEnqueueInput): Promise<boolean>;\n /**\n * Leases the next due pending record exclusively, under the same row-lock\n * semantics as the relay store, or `undefined` when nothing is due.\n */\n leaseNext(input: ApnsLeaseInput): Promise<ApnsDeliveryRecord | undefined>;\n settle(input: ApnsSettleInput): Promise<ApnsSettleResult>;\n read(operationId: string): Promise<ApnsDeliveryRecord | undefined>;\n}\n\nfunction deliveryState(value: unknown): ApnsDeliveryState {\n if (value === \"pending\" || value === \"delivered\" || value === \"dead-lettered\") return value;\n return relayFailure(UNREADABLE, \"apns delivery state is unsupported\");\n}\n\nfunction outcomeKind(value: unknown): ApnsDeliveryOutcome[\"kind\"] | null {\n if (value === null) return null;\n if (\n value === \"delivered\" ||\n value === \"rejected\" ||\n value === \"unreadable\" ||\n value === \"unavailable\"\n ) {\n return value;\n }\n return relayFailure(UNREADABLE, \"apns delivery outcome is unsupported\");\n}\n\nfunction attempts(value: unknown): number {\n const count = timestamp(value, \"attempts\", UNREADABLE);\n if (count > APNS_RETRY_BACKOFF_MS.length + 1) {\n return relayFailure(UNREADABLE, \"apns delivery attempts exceed the schedule\");\n }\n return count;\n}\n\nfunction nullableIdentifier(value: unknown, label: string): string | null {\n return value === null ? null : canonicalIdentifier(value, label, UNREADABLE);\n}\n\nfunction nullableTimestamp(value: unknown, label: string): number | null {\n return value === null ? null : timestamp(value, label, UNREADABLE);\n}\n\n/** Durable storage is a trust boundary, including in memory. */\nexport function parseApnsDeliveryRecord(value: unknown): ApnsDeliveryRecord {\n const context: CaptureContext = new WeakSet();\n const record = exactRecord(\n value,\n [\n \"version\",\n \"operationId\",\n \"deviceToken\",\n \"payload\",\n \"state\",\n \"attempts\",\n \"createdAt\",\n \"availableAt\",\n \"leaseId\",\n \"leaseExpiresAt\",\n \"settledAt\",\n \"lastOutcome\",\n ],\n \"apns delivery record\",\n context,\n (message) => relayFailure(UNREADABLE, message),\n );\n if (record.version !== OAATH_APNS_DELIVERY_RECORD_VERSION) {\n return relayFailure(UNREADABLE, \"apns delivery record version is unsupported\");\n }\n return Object.freeze({\n version: OAATH_APNS_DELIVERY_RECORD_VERSION,\n operationId: canonicalIdentifier(record.operationId, \"operationId\", UNREADABLE),\n deviceToken: boundedText(record.deviceToken, MAX_DEVICE_TOKEN, \"deviceToken\", UNREADABLE),\n payload: boundedText(record.payload, APNS_PAYLOAD_MAX_BYTES, \"payload\", UNREADABLE),\n state: deliveryState(record.state),\n attempts: attempts(record.attempts),\n createdAt: timestamp(record.createdAt, \"createdAt\", UNREADABLE),\n availableAt: timestamp(record.availableAt, \"availableAt\", UNREADABLE),\n leaseId: nullableIdentifier(record.leaseId, \"leaseId\"),\n leaseExpiresAt: nullableTimestamp(record.leaseExpiresAt, \"leaseExpiresAt\"),\n settledAt: nullableTimestamp(record.settledAt, \"settledAt\"),\n lastOutcome: outcomeKind(record.lastOutcome),\n });\n}\n\nfunction settled(\n record: ApnsDeliveryRecord,\n nextState: Exclude<ApnsDeliveryState, \"pending\">,\n kind: ApnsDeliveryOutcome[\"kind\"],\n now: number,\n): ApnsDeliveryRecord {\n return Object.freeze({\n ...record,\n state: nextState,\n attempts: record.attempts + 1,\n leaseId: null,\n leaseExpiresAt: null,\n settledAt: now,\n lastOutcome: kind,\n });\n}\n\n/**\n * Process-local outbox with the durable contract's semantics. It survives no\n * restart: use it for local development and tests only.\n *\n * ponytail: a linear scan picks the next due record and one map holds the queue.\n * Both are fine for a preview push queue; index by `availableAt` only if a\n * deployment ever runs enough of them to notice.\n */\nexport function createMemoryApnsOutbox(\n backoffMs: readonly number[] = APNS_RETRY_BACKOFF_MS,\n): ApnsOutbox {\n const rows = new Map<string, unknown>();\n const load = (operationId: string): ApnsDeliveryRecord | undefined => {\n const stored = rows.get(operationId);\n return stored === undefined ? undefined : parseApnsDeliveryRecord(stored);\n };\n\n return Object.freeze({\n async enqueue(input: ApnsEnqueueInput): Promise<boolean> {\n const operationId = canonicalIdentifier(input.operationId, \"operationId\", UNREADABLE);\n if (rows.has(operationId)) return false;\n rows.set(\n operationId,\n Object.freeze({\n version: OAATH_APNS_DELIVERY_RECORD_VERSION,\n operationId,\n deviceToken: boundedText(input.deviceToken, MAX_DEVICE_TOKEN, \"deviceToken\", UNREADABLE),\n payload: boundedText(input.payload, APNS_PAYLOAD_MAX_BYTES, \"payload\", UNREADABLE),\n state: \"pending\",\n attempts: 0,\n createdAt: timestamp(input.now, \"now\", UNREADABLE),\n availableAt: input.now,\n leaseId: null,\n leaseExpiresAt: null,\n settledAt: null,\n lastOutcome: null,\n }),\n );\n return true;\n },\n\n async leaseNext(input: ApnsLeaseInput): Promise<ApnsDeliveryRecord | undefined> {\n const now = timestamp(input.now, \"now\", UNREADABLE);\n const leaseMs = timestamp(input.leaseMs, \"leaseMs\", UNREADABLE);\n for (const key of rows.keys()) {\n const record = load(key);\n if (!record || record.state !== \"pending\" || record.availableAt > now) continue;\n // A live lease is exclusive; an expired one is reclaimable, because its\n // holder may have crashed mid-attempt.\n if (record.leaseExpiresAt !== null && record.leaseExpiresAt > now) continue;\n const leased = Object.freeze({\n ...record,\n leaseId: randomIdentifier(),\n leaseExpiresAt: now + leaseMs,\n });\n rows.set(key, leased);\n return leased;\n }\n return undefined;\n },\n\n async settle(input: ApnsSettleInput): Promise<ApnsSettleResult> {\n const now = timestamp(input.now, \"now\", UNREADABLE);\n const record = load(input.operationId);\n // Terminal, unleased, or a lost lease: this worker's settlement is void.\n if (!record || record.state !== \"pending\" || record.leaseId !== input.leaseId) {\n return Object.freeze({ settlement: \"stale\" });\n }\n if (input.outcome.kind === \"delivered\") {\n const next = settled(record, \"delivered\", \"delivered\", now);\n rows.set(record.operationId, next);\n return Object.freeze({ settlement: \"delivered\", record: next });\n }\n if (input.outcome.kind === \"rejected\") {\n const next = settled(record, \"dead-lettered\", \"rejected\", now);\n rows.set(record.operationId, next);\n return Object.freeze({ settlement: \"dead-lettered\", record: next });\n }\n const attempt = record.attempts + 1;\n const delay = backoffMs[attempt - 1];\n if (delay === undefined) {\n const next = settled(record, \"dead-lettered\", input.outcome.kind, now);\n rows.set(record.operationId, next);\n return Object.freeze({ settlement: \"dead-lettered\", record: next });\n }\n const next: ApnsDeliveryRecord = Object.freeze({\n ...record,\n attempts: attempt,\n availableAt: now + delay,\n leaseId: null,\n leaseExpiresAt: null,\n lastOutcome: input.outcome.kind,\n });\n rows.set(record.operationId, next);\n return Object.freeze({ settlement: \"retrying\", record: next });\n },\n\n async read(operationId: string): Promise<ApnsDeliveryRecord | undefined> {\n return load(operationId);\n },\n });\n}\n","/**\n * EXPERIMENTAL PREVIEW — settle-once HTTP/2 request lifecycle for APNs.\n *\n * ```text\n * state and owner one request stream; this function owns its single\n * settlement and nothing else\n * persisted evidence none. The outbox owns durable delivery state.\n * resource occupied? the stream, released on settlement\n * retry positively safe? not decided here: this transport never retries\n * transitions open -> settled(delivered|rejected|unreadable|\n * unavailable), exactly once\n * crash/reload nothing to recover: no state outlives the call\n * cleanup owner the settlement path destroys the stream\n * ```\n *\n * One request, one settlement. Response, stream error, and timeout all race, and\n * the first to arrive wins; every later event is dropped. A timeout is\n * `unreadable`, not `unavailable`: the notification may well have been accepted,\n * so the send is unproven, and nothing here may treat it as retryable.\n *\n * Classification is closed and never derived from prose:\n *\n * ```text\n * delivered HTTP 200\n * rejected a definite Apple refusal with a readable `reason`\n * unavailable 429, 500, 503, or a stream that failed before any status\n * unreadable timeout, missing/unparseable status, unreadable refusal body,\n * or a stream that failed after the status arrived\n * ```\n *\n * The session is a port so tests drive a fake stream: no network is contacted.\n * `node:http2`'s `ClientHttp2Session` satisfies it structurally, which\n * `test/apns.test.ts` proves at compile time.\n *\n * @author taek <leekt216@gmail.com>\n */\n\nimport type { ApnsNotification } from \"./sender.js\";\n\n/** Bounded refusal body. Apple's is tiny; anything larger is not read. */\nconst MAX_BODY_BYTES = 4096;\n/** Apple's documented reasons are short ASCII words. */\nconst REASON = /^[A-Za-z]{1,64}$/u;\n\nexport type ApnsDeliveryOutcome =\n | Readonly<{ kind: \"delivered\"; apnsId: string | null }>\n | Readonly<{ kind: \"rejected\"; status: number; reason: string }>\n | Readonly<{ kind: \"unreadable\" }>\n | Readonly<{ kind: \"unavailable\" }>;\n\n/**\n * The subset of an HTTP/2 client stream this transport uses. Every listener\n * payload is `unknown`, because a provider's response is hostile input until it\n * has been read defensively.\n */\nexport interface ApnsStream {\n on(event: \"response\", listener: (headers: unknown) => void): unknown;\n on(event: \"data\", listener: (chunk: unknown) => void): unknown;\n on(event: \"end\", listener: () => void): unknown;\n on(event: \"error\", listener: (error: unknown) => void): unknown;\n setTimeout(milliseconds: number, callback: () => void): unknown;\n end(data?: string): unknown;\n destroy(): unknown;\n}\n\nexport interface ApnsSession {\n request(headers: Readonly<Record<string, string>>): ApnsStream;\n}\n\nexport interface SendApnsNotificationInput {\n readonly session: ApnsSession;\n readonly notification: ApnsNotification;\n readonly timeoutMs: number;\n}\n\nfunction reasonOf(body: string): string | null {\n let parsed: unknown;\n try {\n parsed = JSON.parse(body) as unknown;\n } catch {\n return null;\n }\n if (parsed === null || typeof parsed !== \"object\") return null;\n const reason: unknown = (parsed as Record<string, unknown>).reason;\n return typeof reason === \"string\" && REASON.test(reason) ? reason : null;\n}\n\nfunction classify(status: number | null, apnsId: string | null, body: string): ApnsDeliveryOutcome {\n if (status === 200) return Object.freeze({ kind: \"delivered\", apnsId });\n // Apple's back-pressure and its own faults are provider unavailability.\n if (status === null || status === 429 || status === 500 || status === 503) {\n return Object.freeze({ kind: status === null ? \"unreadable\" : \"unavailable\" });\n }\n const reason = reasonOf(body);\n // A refusal we cannot read is unreadable evidence, never a proven refusal.\n return reason === null\n ? Object.freeze({ kind: \"unreadable\" })\n : Object.freeze({ kind: \"rejected\", status, reason });\n}\n\nfunction headerText(value: unknown): string | null {\n return typeof value === \"string\" ? value : null;\n}\n\n/** Response headers are provider output: read them as data, never as a shape. */\nfunction headerMap(headers: unknown): ReadonlyMap<string, unknown> {\n if (headers === null || typeof headers !== \"object\") return new Map();\n return new Map<string, unknown>(Object.entries(headers));\n}\n\n/**\n * Sends one notification and settles exactly once. It never retries, and it\n * never throws: every outcome, including an unopenable stream, is a classified\n * value the outbox decides on.\n */\nexport function sendApnsNotification(\n input: SendApnsNotificationInput,\n): Promise<ApnsDeliveryOutcome> {\n return new Promise<ApnsDeliveryOutcome>((resolve) => {\n let stream: ApnsStream;\n try {\n stream = input.session.request(input.notification.headers);\n } catch {\n // Nothing was written, so nothing could have been delivered.\n resolve(Object.freeze({ kind: \"unavailable\" }));\n return;\n }\n\n let settled = false;\n let status: number | null = null;\n let apnsId: string | null = null;\n let body = \"\";\n\n const settle = (outcome: ApnsDeliveryOutcome): void => {\n if (settled) return;\n settled = true;\n try {\n stream.destroy();\n } catch {\n // Releasing the stream is cleanup: its failure never changes the outcome.\n }\n resolve(outcome);\n };\n\n stream.on(\"response\", (headers) => {\n const map = headerMap(headers);\n const raw = map.get(\":status\");\n status = typeof raw === \"number\" ? raw : Number.parseInt(headerText(raw) ?? \"\", 10);\n if (!Number.isInteger(status)) status = null;\n apnsId = headerText(map.get(\"apns-id\"));\n });\n stream.on(\"data\", (chunk) => {\n if (body.length >= MAX_BODY_BYTES) return;\n body += typeof chunk === \"string\" ? chunk : String(chunk);\n });\n stream.on(\"end\", () => {\n settle(classify(status, apnsId, body.slice(0, MAX_BODY_BYTES)));\n });\n stream.on(\"error\", () => {\n // Before a status nothing reached Apple's decision; after one, we had a\n // decision we could not finish reading.\n settle(Object.freeze({ kind: status === null ? \"unavailable\" : \"unreadable\" }));\n });\n stream.setTimeout(input.timeoutMs, () => {\n // A timeout is not an absence: the notification may have been accepted.\n settle(Object.freeze({ kind: \"unreadable\" }));\n });\n stream.end(input.notification.payload);\n });\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//#region src/clock.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Injected clock. Expiry is never read from `Date.now()` directly, so tests and
|
|
4
|
+
* deployments own time.
|
|
5
|
+
*
|
|
6
|
+
* @author taek <leekt216@gmail.com>
|
|
7
|
+
*/
|
|
8
|
+
interface RelayClock {
|
|
9
|
+
/** Milliseconds since the Unix epoch as a non-negative safe integer. */
|
|
10
|
+
now(): number;
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/security/authentication.d.ts
|
|
14
|
+
/**
|
|
15
|
+
* Deployment-owned client/device authentication port.
|
|
16
|
+
*
|
|
17
|
+
* The relay never parses credentials. It hands the request to the deployment,
|
|
18
|
+
* which returns the authenticated bindings or nothing. `clientId` and `subject`
|
|
19
|
+
* come only from here: wire input may never declare either.
|
|
20
|
+
*
|
|
21
|
+
* @author taek <leekt216@gmail.com>
|
|
22
|
+
*/
|
|
23
|
+
/** `client` is the requesting application; `owner` is the approving account owner. */
|
|
24
|
+
type RelayCallerRole = "client" | "owner";
|
|
25
|
+
interface RelayCaller {
|
|
26
|
+
readonly role: RelayCallerRole;
|
|
27
|
+
readonly clientId: string;
|
|
28
|
+
/** Pairwise user/device subject. */
|
|
29
|
+
readonly subject: string;
|
|
30
|
+
/**
|
|
31
|
+
* Redirect URIs the deployment registered for this client. An authorization
|
|
32
|
+
* code may only be requested for one of them. `owner` callers pass an empty
|
|
33
|
+
* list; they never receive a code at a redirect URI.
|
|
34
|
+
*/
|
|
35
|
+
readonly redirectUris: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
interface RelayAuthentication {
|
|
38
|
+
/**
|
|
39
|
+
* Resolves to a `RelayCaller`-shaped value, or `null`/`undefined` when the
|
|
40
|
+
* caller is not authenticated. May throw `OaathRelayError` to choose a code.
|
|
41
|
+
*/
|
|
42
|
+
authenticate(request: Request): Promise<unknown>;
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
export { RelayClock as i, RelayCaller as n, RelayCallerRole as r, RelayAuthentication as t };
|
|
46
|
+
//# sourceMappingURL=authentication-HUYWk5Y2.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"authentication-HUYWk5Y2.d.ts","names":[],"sources":["../src/clock.ts","../src/security/authentication.ts"],"mappings":";;;;;;;UAUiB;;EAEf;;;;;;;;;;;;;;KCGU;UAIK;WACN,MAAM;WACN;;WAEA;;;;;;WAMA;;UAGM;;;;;EAKf,aAAa,SAAS,UAAU"}
|