@nifrajs/testing 1.10.0 → 1.12.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/README.md +64 -0
- package/dist/certification.d.ts +181 -0
- package/dist/certification.d.ts.map +1 -0
- package/dist/certification.js +483 -0
- package/dist/certification.js.map +1 -0
- package/dist/failure-lab.d.ts +133 -0
- package/dist/failure-lab.d.ts.map +1 -0
- package/dist/failure-lab.js +207 -0
- package/dist/failure-lab.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +9 -4
- package/src/certification.ts +669 -0
- package/src/failure-lab.ts +356 -0
- package/src/index.ts +39 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile-based adapter certification. Profiles are structural and dependency-free: an adapter package
|
|
3
|
+
* uses this only in its test/CI surface, while the resulting capability matrix is portable JSON evidence.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface CertificationContext {
|
|
7
|
+
/** Deterministic per-check key safe for shared test backends and stable re-runs. */
|
|
8
|
+
readonly key: (suffix: string) => string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CertificationCheck<Adapter> {
|
|
12
|
+
readonly id: string
|
|
13
|
+
readonly capability: string
|
|
14
|
+
readonly run: (adapter: Adapter, context: CertificationContext) => void | Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AdapterCertificationProfile<Adapter> {
|
|
18
|
+
readonly id: string
|
|
19
|
+
readonly version: number
|
|
20
|
+
readonly capabilities: readonly string[]
|
|
21
|
+
readonly checks: readonly CertificationCheck<Adapter>[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CertificationCheckEvidence {
|
|
25
|
+
readonly id: string
|
|
26
|
+
readonly capability: string
|
|
27
|
+
readonly ok: boolean
|
|
28
|
+
/** Error class only. Messages may contain credentials/provider payloads and are never evidence. */
|
|
29
|
+
readonly errorName?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CertificationCapabilityEvidence {
|
|
33
|
+
readonly capability: string
|
|
34
|
+
readonly status: "passed" | "failed"
|
|
35
|
+
readonly checks: readonly string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AdapterCertificationReport {
|
|
39
|
+
readonly schemaVersion: 1
|
|
40
|
+
readonly ok: boolean
|
|
41
|
+
readonly profile: { readonly id: string; readonly version: number }
|
|
42
|
+
readonly adapterId: string
|
|
43
|
+
readonly checks: readonly CertificationCheckEvidence[]
|
|
44
|
+
readonly capabilities: readonly CertificationCapabilityEvidence[]
|
|
45
|
+
readonly evidenceHash: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class AdapterCertificationError extends Error {
|
|
49
|
+
constructor(readonly report: AdapterCertificationReport) {
|
|
50
|
+
const failed = report.checks.filter((check) => !check.ok).map((check) => check.id)
|
|
51
|
+
super(`adapter ${report.adapterId} failed ${report.profile.id}: ${failed.join(", ")}`)
|
|
52
|
+
this.name = "AdapterCertificationError"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const TOKEN = /^[a-z0-9][a-z0-9._:-]{0,127}$/
|
|
57
|
+
const MAX_CHECKS = 128
|
|
58
|
+
|
|
59
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
60
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
61
|
+
|
|
62
|
+
function canonical(value: unknown): string {
|
|
63
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
|
|
64
|
+
if (isRecord(value))
|
|
65
|
+
return `{${Object.keys(value)
|
|
66
|
+
.sort()
|
|
67
|
+
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
|
|
68
|
+
.join(",")}}`
|
|
69
|
+
return JSON.stringify(value)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function sha256(value: string): Promise<string> {
|
|
73
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))
|
|
74
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validateProfile<Adapter>(profile: AdapterCertificationProfile<Adapter>): void {
|
|
78
|
+
if (!TOKEN.test(profile.id))
|
|
79
|
+
throw new TypeError("certification profile id must be a bounded token")
|
|
80
|
+
if (!Number.isInteger(profile.version) || profile.version < 1)
|
|
81
|
+
throw new TypeError("certification profile version must be a positive integer")
|
|
82
|
+
if (profile.checks.length < 1 || profile.checks.length > MAX_CHECKS)
|
|
83
|
+
throw new RangeError(`certification profile must contain 1-${MAX_CHECKS} checks`)
|
|
84
|
+
const capabilities = new Set(profile.capabilities)
|
|
85
|
+
if (capabilities.size !== profile.capabilities.length)
|
|
86
|
+
throw new TypeError("certification profile capability names must be unique")
|
|
87
|
+
const ids = new Set<string>()
|
|
88
|
+
for (const capability of capabilities) {
|
|
89
|
+
if (!TOKEN.test(capability))
|
|
90
|
+
throw new TypeError("certification capability must be a bounded token")
|
|
91
|
+
}
|
|
92
|
+
for (const check of profile.checks) {
|
|
93
|
+
if (!TOKEN.test(check.id) || ids.has(check.id))
|
|
94
|
+
throw new TypeError("certification check ids must be unique bounded tokens")
|
|
95
|
+
if (!capabilities.has(check.capability))
|
|
96
|
+
throw new TypeError(
|
|
97
|
+
`certification check ${check.id} names undeclared capability ${check.capability}`,
|
|
98
|
+
)
|
|
99
|
+
ids.add(check.id)
|
|
100
|
+
}
|
|
101
|
+
for (const capability of capabilities) {
|
|
102
|
+
if (!profile.checks.some((check) => check.capability === capability))
|
|
103
|
+
throw new TypeError(`certification capability has no checks: ${capability}`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Define and validate a custom domain/provider profile at module initialization. */
|
|
108
|
+
export function defineCertificationProfile<Adapter>(
|
|
109
|
+
profile: AdapterCertificationProfile<Adapter>,
|
|
110
|
+
): AdapterCertificationProfile<Adapter> {
|
|
111
|
+
validateProfile(profile)
|
|
112
|
+
return profile
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function certifyAdapter<Adapter>(options: {
|
|
116
|
+
readonly profile: AdapterCertificationProfile<Adapter>
|
|
117
|
+
readonly adapterId: string
|
|
118
|
+
/** A fresh adapter per check prevents one failed check from contaminating the next. */
|
|
119
|
+
readonly createAdapter: () => Adapter | Promise<Adapter>
|
|
120
|
+
readonly cleanup?: (adapter: Adapter) => void | Promise<void>
|
|
121
|
+
}): Promise<AdapterCertificationReport> {
|
|
122
|
+
validateProfile(options.profile)
|
|
123
|
+
if (!TOKEN.test(options.adapterId)) throw new TypeError("adapter id must be a bounded token")
|
|
124
|
+
const checks: CertificationCheckEvidence[] = []
|
|
125
|
+
for (const check of options.profile.checks) {
|
|
126
|
+
let adapter: Adapter | undefined
|
|
127
|
+
try {
|
|
128
|
+
adapter = await options.createAdapter()
|
|
129
|
+
await check.run(adapter, {
|
|
130
|
+
key: (suffix) =>
|
|
131
|
+
`nifra-cert:${options.profile.id}:${options.adapterId}:${check.id}:${suffix}`,
|
|
132
|
+
})
|
|
133
|
+
checks.push({ id: check.id, capability: check.capability, ok: true })
|
|
134
|
+
} catch (error) {
|
|
135
|
+
checks.push({
|
|
136
|
+
id: check.id,
|
|
137
|
+
capability: check.capability,
|
|
138
|
+
ok: false,
|
|
139
|
+
errorName:
|
|
140
|
+
error instanceof Error && TOKEN.test(error.name.toLowerCase())
|
|
141
|
+
? error.name
|
|
142
|
+
: "AdapterError",
|
|
143
|
+
})
|
|
144
|
+
} finally {
|
|
145
|
+
if (adapter !== undefined && options.cleanup !== undefined) {
|
|
146
|
+
try {
|
|
147
|
+
await options.cleanup(adapter)
|
|
148
|
+
} catch {
|
|
149
|
+
// Cleanup cannot turn a failed functional check into success, and provider messages are not
|
|
150
|
+
// persisted. Adapter suites should make cleanup idempotent and observable separately.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const capabilities = options.profile.capabilities.map((capability) => {
|
|
156
|
+
const ids = checks.filter((check) => check.capability === capability)
|
|
157
|
+
return {
|
|
158
|
+
capability,
|
|
159
|
+
status: ids.every((check) => check.ok) ? ("passed" as const) : ("failed" as const),
|
|
160
|
+
checks: ids.map((check) => check.id),
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
const body = {
|
|
164
|
+
schemaVersion: 1 as const,
|
|
165
|
+
ok: checks.every((check) => check.ok),
|
|
166
|
+
profile: { id: options.profile.id, version: options.profile.version },
|
|
167
|
+
adapterId: options.adapterId,
|
|
168
|
+
checks,
|
|
169
|
+
capabilities,
|
|
170
|
+
}
|
|
171
|
+
return { ...body, evidenceHash: await sha256(canonical(body)) }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function assertAdapterCertification(report: AdapterCertificationReport): void {
|
|
175
|
+
if (!report.ok) throw new AdapterCertificationError(report)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Recompute the portable evidence hash. Consumers should verify before trusting a stored report. */
|
|
179
|
+
export async function verifyAdapterCertification(
|
|
180
|
+
report: AdapterCertificationReport,
|
|
181
|
+
): Promise<boolean> {
|
|
182
|
+
const { evidenceHash, ...body } = report
|
|
183
|
+
return evidenceHash === (await sha256(canonical(body)))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Standard profiles ───────────────────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
export interface CertifiableCacheEntry {
|
|
189
|
+
readonly value: unknown
|
|
190
|
+
readonly staleAt: number
|
|
191
|
+
readonly expiresAt: number
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface CertifiableCacheStore {
|
|
195
|
+
get(key: string): CertifiableCacheEntry | undefined | Promise<CertifiableCacheEntry | undefined>
|
|
196
|
+
set(key: string, entry: CertifiableCacheEntry, tags: readonly string[]): void | Promise<void>
|
|
197
|
+
delete(key: string): void | Promise<void>
|
|
198
|
+
invalidateTag(tag: string): void | Promise<void>
|
|
199
|
+
clear(): void | Promise<void>
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function cacheStoreCertificationProfile(): AdapterCertificationProfile<CertifiableCacheStore> {
|
|
203
|
+
const entry = (value: unknown): CertifiableCacheEntry => ({
|
|
204
|
+
value,
|
|
205
|
+
staleAt: 5_000_000,
|
|
206
|
+
expiresAt: 6_000_000,
|
|
207
|
+
})
|
|
208
|
+
return {
|
|
209
|
+
id: "cache-store",
|
|
210
|
+
version: 1,
|
|
211
|
+
capabilities: ["roundtrip", "tag-invalidation", "clear"],
|
|
212
|
+
checks: [
|
|
213
|
+
{
|
|
214
|
+
id: "set-get-delete",
|
|
215
|
+
capability: "roundtrip",
|
|
216
|
+
async run(store, context) {
|
|
217
|
+
const key = context.key("roundtrip")
|
|
218
|
+
await store.set(key, entry({ answer: 42 }), [])
|
|
219
|
+
const found = await store.get(key)
|
|
220
|
+
if (JSON.stringify(found?.value) !== '{"answer":42}') throw new Error("CacheRoundtrip")
|
|
221
|
+
await store.delete(key)
|
|
222
|
+
if ((await store.get(key)) !== undefined) throw new Error("CacheDelete")
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: "invalidate-tag",
|
|
227
|
+
capability: "tag-invalidation",
|
|
228
|
+
async run(store, context) {
|
|
229
|
+
const tagged = context.key("tagged")
|
|
230
|
+
const other = context.key("other")
|
|
231
|
+
const tag = context.key("tag")
|
|
232
|
+
await store.set(tagged, entry(1), [tag])
|
|
233
|
+
await store.set(other, entry(2), [context.key("other-tag")])
|
|
234
|
+
await store.invalidateTag(tag)
|
|
235
|
+
if ((await store.get(tagged)) !== undefined || (await store.get(other))?.value !== 2)
|
|
236
|
+
throw new Error("CacheTagInvalidation")
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: "clear",
|
|
241
|
+
capability: "clear",
|
|
242
|
+
async run(store, context) {
|
|
243
|
+
const key = context.key("clear")
|
|
244
|
+
await store.set(key, entry(1), [])
|
|
245
|
+
await store.clear()
|
|
246
|
+
if ((await store.get(key)) !== undefined) throw new Error("CacheClear")
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface CertifiableStoredJob {
|
|
254
|
+
readonly id: string
|
|
255
|
+
readonly name: string
|
|
256
|
+
readonly payload: unknown
|
|
257
|
+
readonly attempt: number
|
|
258
|
+
readonly maxAttempts: number
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface CertifiableJobStore {
|
|
262
|
+
enqueue(job: {
|
|
263
|
+
name: string
|
|
264
|
+
payload: unknown
|
|
265
|
+
runAt: number
|
|
266
|
+
maxAttempts: number
|
|
267
|
+
}): string | Promise<string>
|
|
268
|
+
lease(
|
|
269
|
+
now: number,
|
|
270
|
+
limit: number,
|
|
271
|
+
leaseMs: number,
|
|
272
|
+
): CertifiableStoredJob[] | Promise<CertifiableStoredJob[]>
|
|
273
|
+
complete(id: string): void | Promise<void>
|
|
274
|
+
retry(id: string, runAt: number): void | Promise<void>
|
|
275
|
+
deadLetter(id: string, error: string): void | Promise<void>
|
|
276
|
+
counts():
|
|
277
|
+
| { pending: number; active: number; dead: number }
|
|
278
|
+
| Promise<{
|
|
279
|
+
pending: number
|
|
280
|
+
active: number
|
|
281
|
+
dead: number
|
|
282
|
+
}>
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function jobStoreCertificationProfile(): AdapterCertificationProfile<CertifiableJobStore> {
|
|
286
|
+
return {
|
|
287
|
+
id: "job-store",
|
|
288
|
+
version: 1,
|
|
289
|
+
capabilities: ["lease-complete", "retry-schedule", "dead-letter"],
|
|
290
|
+
checks: [
|
|
291
|
+
{
|
|
292
|
+
id: "lease-complete",
|
|
293
|
+
capability: "lease-complete",
|
|
294
|
+
async run(store) {
|
|
295
|
+
const id = await store.enqueue({
|
|
296
|
+
name: "cert",
|
|
297
|
+
payload: { n: 1 },
|
|
298
|
+
runAt: 10,
|
|
299
|
+
maxAttempts: 3,
|
|
300
|
+
})
|
|
301
|
+
if ((await store.lease(9, 1, 100)).length !== 0) throw new Error("JobEarlyLease")
|
|
302
|
+
const leased = await store.lease(10, 1, 100)
|
|
303
|
+
if (leased.length !== 1 || leased[0]?.id !== id || leased[0]?.attempt !== 0)
|
|
304
|
+
throw new Error("JobLease")
|
|
305
|
+
await store.complete(id)
|
|
306
|
+
if ((await store.counts()).pending !== 0) throw new Error("JobComplete")
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
id: "retry-schedule",
|
|
311
|
+
capability: "retry-schedule",
|
|
312
|
+
async run(store) {
|
|
313
|
+
const id = await store.enqueue({ name: "cert", payload: null, runAt: 0, maxAttempts: 3 })
|
|
314
|
+
await store.lease(0, 1, 10)
|
|
315
|
+
await store.retry(id, 20)
|
|
316
|
+
if ((await store.lease(19, 1, 10)).length !== 0) throw new Error("JobRetryEarly")
|
|
317
|
+
const retried = await store.lease(20, 1, 10)
|
|
318
|
+
if (retried[0]?.attempt !== 1) throw new Error("JobRetryAttempt")
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: "dead-letter",
|
|
323
|
+
capability: "dead-letter",
|
|
324
|
+
async run(store) {
|
|
325
|
+
const id = await store.enqueue({ name: "cert", payload: null, runAt: 0, maxAttempts: 1 })
|
|
326
|
+
await store.deadLetter(id, "bounded-code")
|
|
327
|
+
const counts = await store.counts()
|
|
328
|
+
if (counts.dead !== 1 || counts.pending !== 0) throw new Error("JobDeadLetter")
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface CertifiableStorageObject {
|
|
336
|
+
readonly body: Uint8Array
|
|
337
|
+
readonly size: number
|
|
338
|
+
readonly contentType?: string
|
|
339
|
+
readonly metadata?: Readonly<Record<string, string>>
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface CertifiableStorageAdapter {
|
|
343
|
+
put(
|
|
344
|
+
key: string,
|
|
345
|
+
data: Uint8Array | ArrayBuffer | string,
|
|
346
|
+
options?: {
|
|
347
|
+
readonly contentType?: string
|
|
348
|
+
readonly metadata?: Readonly<Record<string, string>>
|
|
349
|
+
},
|
|
350
|
+
): Promise<void>
|
|
351
|
+
get(key: string): Promise<CertifiableStorageObject | null>
|
|
352
|
+
delete(key: string): Promise<void>
|
|
353
|
+
exists(key: string): Promise<boolean>
|
|
354
|
+
list(options?: { readonly prefix?: string; readonly limit?: number }): Promise<string[]>
|
|
355
|
+
listPage?(options?: {
|
|
356
|
+
readonly prefix?: string
|
|
357
|
+
readonly limit?: number
|
|
358
|
+
readonly cursor?: string
|
|
359
|
+
}): Promise<{ readonly keys: readonly string[]; readonly cursor?: string }>
|
|
360
|
+
presign?(
|
|
361
|
+
key: string,
|
|
362
|
+
operation: "get" | "put",
|
|
363
|
+
options?: {
|
|
364
|
+
readonly expiresInSeconds?: number
|
|
365
|
+
readonly contentType?: string
|
|
366
|
+
readonly contentLength?: number
|
|
367
|
+
},
|
|
368
|
+
): Promise<{ readonly url: string; readonly expiresAt?: Date }>
|
|
369
|
+
copy?(sourceKey: string, destinationKey: string): Promise<void>
|
|
370
|
+
move?(sourceKey: string, destinationKey: string): Promise<void>
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function storageAdapterCertificationProfile(
|
|
374
|
+
options: { readonly paging?: boolean; readonly presign?: boolean; readonly move?: boolean } = {},
|
|
375
|
+
): AdapterCertificationProfile<CertifiableStorageAdapter> {
|
|
376
|
+
const capabilities = ["object-roundtrip", "overwrite", "list-delete"]
|
|
377
|
+
const checks: CertificationCheck<CertifiableStorageAdapter>[] = [
|
|
378
|
+
{
|
|
379
|
+
id: "object-roundtrip",
|
|
380
|
+
capability: "object-roundtrip",
|
|
381
|
+
async run(store, context) {
|
|
382
|
+
const key = context.key("object")
|
|
383
|
+
await store.put(key, "hello", { contentType: "text/plain", metadata: { owner: "cert" } })
|
|
384
|
+
const object = await store.get(key)
|
|
385
|
+
if (
|
|
386
|
+
object === null ||
|
|
387
|
+
new TextDecoder().decode(object.body) !== "hello" ||
|
|
388
|
+
object.size !== 5 ||
|
|
389
|
+
object.contentType !== "text/plain" ||
|
|
390
|
+
object.metadata?.owner !== "cert"
|
|
391
|
+
)
|
|
392
|
+
throw new Error("StorageRoundtrip")
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
id: "overwrite",
|
|
397
|
+
capability: "overwrite",
|
|
398
|
+
async run(store, context) {
|
|
399
|
+
const key = context.key("overwrite")
|
|
400
|
+
await store.put(key, "before")
|
|
401
|
+
await store.put(key, "after")
|
|
402
|
+
const object = await store.get(key)
|
|
403
|
+
if (object === null || new TextDecoder().decode(object.body) !== "after")
|
|
404
|
+
throw new Error("StorageOverwrite")
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
id: "list-delete",
|
|
409
|
+
capability: "list-delete",
|
|
410
|
+
async run(store, context) {
|
|
411
|
+
const prefix = `${context.key("prefix")}/`
|
|
412
|
+
const key = `${prefix}a`
|
|
413
|
+
await store.put(key, "x")
|
|
414
|
+
await store.put(context.key("outside"), "x")
|
|
415
|
+
const listed = await store.list({ prefix, limit: 1 })
|
|
416
|
+
if (listed.length !== 1 || listed[0] !== key || !(await store.exists(key)))
|
|
417
|
+
throw new Error("StorageList")
|
|
418
|
+
await store.delete(key)
|
|
419
|
+
if ((await store.exists(key)) || (await store.get(key)) !== null)
|
|
420
|
+
throw new Error("StorageDelete")
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
]
|
|
424
|
+
if (options.paging === true) {
|
|
425
|
+
capabilities.push("cursor-paging")
|
|
426
|
+
checks.push({
|
|
427
|
+
id: "cursor-paging",
|
|
428
|
+
capability: "cursor-paging",
|
|
429
|
+
async run(store, context) {
|
|
430
|
+
if (store.listPage === undefined) throw new Error("StoragePagingMissing")
|
|
431
|
+
const prefix = `${context.key("page")}/`
|
|
432
|
+
await store.put(`${prefix}a`, "a")
|
|
433
|
+
await store.put(`${prefix}b`, "b")
|
|
434
|
+
const first = await store.listPage({ prefix, limit: 1 })
|
|
435
|
+
if (first.keys.length !== 1 || first.cursor === undefined)
|
|
436
|
+
throw new Error("StoragePagingFirst")
|
|
437
|
+
const second = await store.listPage({ prefix, limit: 1, cursor: first.cursor })
|
|
438
|
+
if (second.keys.length !== 1 || second.keys[0] === first.keys[0])
|
|
439
|
+
throw new Error("StoragePagingSecond")
|
|
440
|
+
},
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
if (options.presign === true) {
|
|
444
|
+
capabilities.push("presign")
|
|
445
|
+
checks.push({
|
|
446
|
+
id: "presign",
|
|
447
|
+
capability: "presign",
|
|
448
|
+
async run(store, context) {
|
|
449
|
+
if (store.presign === undefined) throw new Error("StoragePresignMissing")
|
|
450
|
+
const signed = await store.presign(context.key("signed"), "put", {
|
|
451
|
+
expiresInSeconds: 60,
|
|
452
|
+
contentType: "text/plain",
|
|
453
|
+
contentLength: 1,
|
|
454
|
+
})
|
|
455
|
+
const url = new URL(signed.url)
|
|
456
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
457
|
+
throw new Error("StoragePresignUrl")
|
|
458
|
+
},
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
if (options.move === true) {
|
|
462
|
+
capabilities.push("copy-move")
|
|
463
|
+
checks.push({
|
|
464
|
+
id: "copy-move",
|
|
465
|
+
capability: "copy-move",
|
|
466
|
+
async run(store, context) {
|
|
467
|
+
if (store.copy === undefined || store.move === undefined)
|
|
468
|
+
throw new Error("StorageMoveMissing")
|
|
469
|
+
const source = context.key("source")
|
|
470
|
+
const copy = context.key("copy")
|
|
471
|
+
const moved = context.key("moved")
|
|
472
|
+
await store.put(source, "x")
|
|
473
|
+
await store.copy(source, copy)
|
|
474
|
+
await store.move(copy, moved)
|
|
475
|
+
if (
|
|
476
|
+
!(await store.exists(source)) ||
|
|
477
|
+
(await store.exists(copy)) ||
|
|
478
|
+
!(await store.exists(moved))
|
|
479
|
+
)
|
|
480
|
+
throw new Error("StorageMove")
|
|
481
|
+
},
|
|
482
|
+
})
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
id: "storage-adapter",
|
|
486
|
+
version: 1,
|
|
487
|
+
capabilities,
|
|
488
|
+
checks,
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export interface CertifiableRuntimeServer {
|
|
493
|
+
readonly origin: string
|
|
494
|
+
stop(): void | Promise<void>
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export interface CertifiableRuntimeAdapter {
|
|
498
|
+
start(app: {
|
|
499
|
+
fetch(request: Request): Response | Promise<Response>
|
|
500
|
+
}): CertifiableRuntimeServer | Promise<CertifiableRuntimeServer>
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function runtimeAdapterCertificationProfile(): AdapterCertificationProfile<CertifiableRuntimeAdapter> {
|
|
504
|
+
return {
|
|
505
|
+
id: "runtime-adapter",
|
|
506
|
+
version: 1,
|
|
507
|
+
capabilities: ["request-bridge", "response-bridge", "lifecycle"],
|
|
508
|
+
checks: [
|
|
509
|
+
{
|
|
510
|
+
id: "request-bridge",
|
|
511
|
+
capability: "request-bridge",
|
|
512
|
+
async run(adapter) {
|
|
513
|
+
const server = await adapter.start({
|
|
514
|
+
async fetch(request) {
|
|
515
|
+
return Response.json({
|
|
516
|
+
method: request.method,
|
|
517
|
+
path: new URL(request.url).pathname,
|
|
518
|
+
header: request.headers.get("x-cert"),
|
|
519
|
+
body: await request.text(),
|
|
520
|
+
})
|
|
521
|
+
},
|
|
522
|
+
})
|
|
523
|
+
try {
|
|
524
|
+
const response = await fetch(`${server.origin}/cert/path`, {
|
|
525
|
+
method: "POST",
|
|
526
|
+
headers: { "x-cert": "yes" },
|
|
527
|
+
body: "payload",
|
|
528
|
+
})
|
|
529
|
+
const value = (await response.json()) as Record<string, unknown>
|
|
530
|
+
if (
|
|
531
|
+
value.method !== "POST" ||
|
|
532
|
+
value.path !== "/cert/path" ||
|
|
533
|
+
value.header !== "yes" ||
|
|
534
|
+
value.body !== "payload"
|
|
535
|
+
)
|
|
536
|
+
throw new Error("RuntimeRequestBridge")
|
|
537
|
+
} finally {
|
|
538
|
+
await server.stop()
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
id: "response-bridge",
|
|
544
|
+
capability: "response-bridge",
|
|
545
|
+
async run(adapter) {
|
|
546
|
+
const server = await adapter.start({
|
|
547
|
+
fetch: () => new Response("certified", { status: 201, headers: { "x-cert": "yes" } }),
|
|
548
|
+
})
|
|
549
|
+
try {
|
|
550
|
+
const response = await fetch(`${server.origin}/`)
|
|
551
|
+
if (
|
|
552
|
+
response.status !== 201 ||
|
|
553
|
+
response.headers.get("x-cert") !== "yes" ||
|
|
554
|
+
(await response.text()) !== "certified"
|
|
555
|
+
)
|
|
556
|
+
throw new Error("RuntimeResponseBridge")
|
|
557
|
+
} finally {
|
|
558
|
+
await server.stop()
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
id: "idempotent-stop",
|
|
564
|
+
capability: "lifecycle",
|
|
565
|
+
async run(adapter) {
|
|
566
|
+
const server = await adapter.start({ fetch: () => new Response(null, { status: 204 }) })
|
|
567
|
+
if (!/^https?:\/\//.test(server.origin)) throw new Error("RuntimeOrigin")
|
|
568
|
+
await server.stop()
|
|
569
|
+
await server.stop()
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export interface CertifiableDomainEvent {
|
|
577
|
+
readonly id: string
|
|
578
|
+
readonly type: string
|
|
579
|
+
readonly version?: number
|
|
580
|
+
readonly aggregateId: string
|
|
581
|
+
readonly payload: unknown
|
|
582
|
+
readonly timestamp: string
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export interface CertifiableEventRecord {
|
|
586
|
+
readonly position: string
|
|
587
|
+
readonly event: CertifiableDomainEvent
|
|
588
|
+
readonly claimId: string | null
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export interface CertifiableEventDeliveryAdapter {
|
|
592
|
+
append(event: CertifiableDomainEvent): Promise<void>
|
|
593
|
+
claimPending(limit: number, now: string): Promise<CertifiableEventRecord[]>
|
|
594
|
+
markDelivered(id: string, claimId: string, at: string): Promise<boolean>
|
|
595
|
+
readPage(options?: { readonly after?: string; readonly limit?: number }): Promise<{
|
|
596
|
+
readonly records: readonly CertifiableEventRecord[]
|
|
597
|
+
readonly nextPosition: string | null
|
|
598
|
+
readonly hasMore: boolean
|
|
599
|
+
}>
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const certEvent = (id: string, order: number): CertifiableDomainEvent => ({
|
|
603
|
+
id,
|
|
604
|
+
type: "nifra.certified",
|
|
605
|
+
version: 1,
|
|
606
|
+
aggregateId: "certification",
|
|
607
|
+
payload: { order },
|
|
608
|
+
timestamp: new Date(order * 1_000).toISOString(),
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
export function eventDeliveryCertificationProfile(): AdapterCertificationProfile<CertifiableEventDeliveryAdapter> {
|
|
612
|
+
return {
|
|
613
|
+
id: "event-delivery",
|
|
614
|
+
version: 1,
|
|
615
|
+
capabilities: ["idempotent-append", "claim-ownership", "ordered-replay"],
|
|
616
|
+
checks: [
|
|
617
|
+
{
|
|
618
|
+
id: "idempotent-append",
|
|
619
|
+
capability: "idempotent-append",
|
|
620
|
+
async run(store, context) {
|
|
621
|
+
const event = certEvent(context.key("event"), 1)
|
|
622
|
+
await store.append(event)
|
|
623
|
+
await store.append(event)
|
|
624
|
+
const page = await store.readPage({ limit: 10 })
|
|
625
|
+
if (page.records.filter((record) => record.event.id === event.id).length !== 1)
|
|
626
|
+
throw new Error("EventAppendIdempotency")
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
id: "claim-ownership",
|
|
631
|
+
capability: "claim-ownership",
|
|
632
|
+
async run(store, context) {
|
|
633
|
+
const event = certEvent(context.key("claim"), 1)
|
|
634
|
+
await store.append(event)
|
|
635
|
+
const claimed = await store.claimPending(1, "2026-01-01T00:00:00.000Z")
|
|
636
|
+
const claimId = claimed[0]?.claimId
|
|
637
|
+
if (claimed[0]?.event.id !== event.id || claimId === null || claimId === undefined)
|
|
638
|
+
throw new Error("EventClaim")
|
|
639
|
+
if (await store.markDelivered(event.id, "wrong-claim", "2026-01-01T00:00:01.000Z"))
|
|
640
|
+
throw new Error("EventClaimFence")
|
|
641
|
+
if (!(await store.markDelivered(event.id, claimId, "2026-01-01T00:00:01.000Z")))
|
|
642
|
+
throw new Error("EventSettlement")
|
|
643
|
+
if ((await store.claimPending(1, "2026-01-01T00:00:02.000Z")).length !== 0)
|
|
644
|
+
throw new Error("EventRedelivery")
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
id: "ordered-replay",
|
|
649
|
+
capability: "ordered-replay",
|
|
650
|
+
async run(store, context) {
|
|
651
|
+
const first = certEvent(context.key("first"), 1)
|
|
652
|
+
const second = certEvent(context.key("second"), 2)
|
|
653
|
+
await store.append(first)
|
|
654
|
+
await store.append(second)
|
|
655
|
+
const pageOne = await store.readPage({ limit: 1 })
|
|
656
|
+
if (
|
|
657
|
+
pageOne.records[0]?.event.id !== first.id ||
|
|
658
|
+
pageOne.nextPosition === null ||
|
|
659
|
+
!pageOne.hasMore
|
|
660
|
+
)
|
|
661
|
+
throw new Error("EventReplayPageOne")
|
|
662
|
+
const pageTwo = await store.readPage({ after: pageOne.nextPosition, limit: 1 })
|
|
663
|
+
if (pageTwo.records[0]?.event.id !== second.id || pageTwo.hasMore)
|
|
664
|
+
throw new Error("EventReplayPageTwo")
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
],
|
|
668
|
+
}
|
|
669
|
+
}
|