@learncard/types 5.21.0 → 5.22.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.
@@ -0,0 +1,856 @@
1
+ import { z } from 'zod/v4';
2
+
3
+ import { JWEValidator } from './crypto';
4
+ import { PaginationResponseValidator } from './mongo';
5
+ import { VCValidator, VPValidator } from './vc';
6
+
7
+ /**
8
+ * LC-2187 multi-credential share-link protocol types.
9
+ *
10
+ * This module is the single source of truth for the *recipient* payload grammar
11
+ * and the owner-facing recovery/API shapes. It is deliberately storage-agnostic:
12
+ * it defines bytes and structure, never where those bytes live.
13
+ *
14
+ * Security boundaries encoded here (see planning/lc-2187/task-a-contract.md and
15
+ * task-a2-review.md):
16
+ * - A share id / content key / IV / ciphertext is accepted only when it is
17
+ * canonical base64url: alphabet, no `=`, exact decoded byte length, and a
18
+ * re-encode round trip. Permissive decoders accept unused padding bits, so one
19
+ * byte string would otherwise have several spellings and could defeat an AAD
20
+ * or signature binding keyed on the string.
21
+ * - The recipient manifest never carries source URIs. Credentials are referenced
22
+ * by local index into the holder-signed presentation only.
23
+ * - Envelope authenticity (AES-GCM) only proves a key holder produced the bytes.
24
+ * It is NOT issuer identity. Signed VP/credential verification is a separate,
25
+ * later step (see `ShareManifestProofVerifier` in learn-card-base).
26
+ */
27
+
28
+ /** Raw byte length of the opaque share id (128 bits). */
29
+ export const SHARE_LINK_ID_BYTES = 16;
30
+ /** Raw byte length of the symmetric content key (256 bits). */
31
+ export const SHARE_CONTENT_KEY_BYTES = 32;
32
+ /** AES-GCM nonce length (96 bits). */
33
+ export const SHARE_IV_BYTES = 12;
34
+ /** AES-GCM authentication tag length (128 bits). */
35
+ export const SHARE_TAG_BYTES = 16;
36
+ /** Maximum decoded ciphertext length, tag included (512 KiB). */
37
+ export const MAX_SHARE_CIPHERTEXT_BYTES = 512 * 1024;
38
+ /** Maximum UTF-8 length of the serialized owner recovery JWE (64 KiB). */
39
+ export const MAX_SHARE_RECOVERY_JWE_BYTES = 64 * 1024;
40
+ /** Maximum number of selected credentials in one share (50). */
41
+ export const MAX_SELECTED_CREDENTIALS = 50;
42
+ /** Maximum number of public endorsements attached to one share (200). */
43
+ export const MAX_ENDORSEMENTS = 200;
44
+ /**
45
+ * Conservative total bound on holder-signed VP members (selected + endorsements).
46
+ * The sum is intentional: the two collections are disjoint and every member must
47
+ * be classified exactly once, so their maximums bound the VP.
48
+ */
49
+ export const MAX_VP_MEMBERS = MAX_SELECTED_CREDENTIALS + MAX_ENDORSEMENTS;
50
+ /** Maximum length of an owner-private source URI in recovery. */
51
+ export const MAX_SHARE_SOURCE_URI_CHARS = 2048;
52
+ /**
53
+ * Maximum UTF-8 byte length of one complete owner share-link request body,
54
+ * measured raw (before Zod strips unknown fields). This is the transport-level
55
+ * bound enforced ahead of schema parsing; individual field bounds are not a
56
+ * substitute for it.
57
+ */
58
+ export const MAX_SHARE_LINK_REQUEST_BYTES = 1024 * 1024;
59
+
60
+ /** Recipient plaintext protocol id. */
61
+ export const SHARE_LINK_PROTOCOL = 'lc-share/v1';
62
+ /** Owner recovery plaintext protocol id. */
63
+ export const SHARE_RECOVERY_PROTOCOL = 'lc-share-recovery/v1';
64
+ /** Domain-separation prefix for the AES-GCM AAD. */
65
+ export const SHARE_AAD_PREFIX = 'lc-share:v1';
66
+
67
+ const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
68
+
69
+ /** Reverse lookup for the base64url alphabet; -1 marks an invalid character. */
70
+ const BASE64URL_LOOKUP = (() => {
71
+ const table = new Int16Array(128).fill(-1);
72
+ for (let index = 0; index < BASE64URL_ALPHABET.length; index += 1) {
73
+ table[BASE64URL_ALPHABET.charCodeAt(index)] = index;
74
+ }
75
+ return table;
76
+ })();
77
+
78
+ /**
79
+ * Encode bytes as unpadded base64url. Browser/Node isomorphic on purpose: no
80
+ * `Buffer`, and no dependency on `btoa`/`atob` encoding quirks.
81
+ */
82
+ export const encodeBase64Url = (bytes: Uint8Array): string => {
83
+ let output = '';
84
+
85
+ for (let index = 0; index < bytes.length; index += 3) {
86
+ const first = bytes[index];
87
+ const second = bytes[index + 1];
88
+ const third = bytes[index + 2];
89
+
90
+ output += BASE64URL_ALPHABET[first >> 2];
91
+ output += BASE64URL_ALPHABET[((first & 0b11) << 4) | ((second ?? 0) >> 4)];
92
+ if (second === undefined) break;
93
+ output += BASE64URL_ALPHABET[((second & 0b1111) << 2) | ((third ?? 0) >> 6)];
94
+ if (third === undefined) break;
95
+ output += BASE64URL_ALPHABET[third & 0b111111];
96
+ }
97
+
98
+ return output;
99
+ };
100
+
101
+ /**
102
+ * Decode unpadded base64url to bytes. Returns `null` for any non-alphabet,
103
+ * padding or otherwise malformed input. This step is intentionally more lenient
104
+ * about trailing padding bits than {@link isCanonicalBase64Url}; callers that
105
+ * need one unambiguous spelling must re-encode and compare.
106
+ */
107
+ export const decodeBase64Url = (value: string): Uint8Array | null => {
108
+ if (typeof value !== 'string') return null;
109
+
110
+ const output = new Uint8Array(Math.floor((value.length * 6) / 8));
111
+ let bits = 0;
112
+ let bitCount = 0;
113
+ let outputIndex = 0;
114
+
115
+ for (let index = 0; index < value.length; index += 1) {
116
+ const code = value.charCodeAt(index);
117
+ const digit = code < 128 ? BASE64URL_LOOKUP[code] : -1;
118
+ if (digit < 0) return null;
119
+
120
+ bits = (bits << 6) | digit;
121
+ bitCount += 6;
122
+
123
+ if (bitCount >= 8) {
124
+ bitCount -= 8;
125
+ output[outputIndex] = (bits >> bitCount) & 0xff;
126
+ outputIndex += 1;
127
+ }
128
+ }
129
+
130
+ return output;
131
+ };
132
+
133
+ /**
134
+ * Strict canonicity check: valid alphabet, no padding, and
135
+ * `encode(decode(value)) === value`. When `expectedBytes` is supplied the decoded
136
+ * length must match exactly.
137
+ */
138
+ export const isCanonicalBase64Url = (value: unknown, expectedBytes?: number): boolean => {
139
+ if (typeof value !== 'string') return false;
140
+ if (expectedBytes !== undefined && value.length !== Math.ceil((expectedBytes * 4) / 3))
141
+ return false;
142
+
143
+ const decoded = decodeBase64Url(value);
144
+ if (decoded === null) return false;
145
+ if (expectedBytes !== undefined && decoded.length !== expectedBytes) return false;
146
+
147
+ return encodeBase64Url(decoded) === value;
148
+ };
149
+
150
+ /** Base64url-encoded byte length without materializing the decoded string. */
151
+ export const decodedBase64UrlByteLength = (value: string): number | null =>
152
+ decodeBase64Url(value)?.length ?? null;
153
+
154
+ /** UTF-8 byte length, independent of the platform's string length semantics. */
155
+ export const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).length;
156
+
157
+ const canonicalBase64Url = (bytes: number) =>
158
+ z.string().refine(value => isCanonicalBase64Url(value, bytes), {
159
+ message: `must be canonical base64url for exactly ${bytes} bytes`,
160
+ });
161
+
162
+ /** 16-byte / 22-character share id. */
163
+ export const ShareLinkIdValidator = canonicalBase64Url(SHARE_LINK_ID_BYTES);
164
+ export type ShareLinkId = z.infer<typeof ShareLinkIdValidator>;
165
+
166
+ /** 32-byte / 43-character symmetric content key. */
167
+ export const ShareContentKeyValidator = canonicalBase64Url(SHARE_CONTENT_KEY_BYTES);
168
+ export type ShareContentKey = z.infer<typeof ShareContentKeyValidator>;
169
+
170
+ /** 12-byte / 16-character AES-GCM nonce. */
171
+ export const ShareIvValidator = canonicalBase64Url(SHARE_IV_BYTES);
172
+ export type ShareIv = z.infer<typeof ShareIvValidator>;
173
+
174
+ /**
175
+ * Ciphertext||tag as canonical base64url. The decoded length includes the
176
+ * 16-byte GCM tag, must be at least that tag, and is capped at
177
+ * {@link MAX_SHARE_CIPHERTEXT_BYTES}.
178
+ */
179
+ export const ShareCiphertextValidator = z.string().refine(
180
+ value => {
181
+ if (value.length > Math.ceil((MAX_SHARE_CIPHERTEXT_BYTES * 4) / 3)) return false;
182
+ const decoded = decodeBase64Url(value);
183
+ if (decoded === null) return false;
184
+ if (encodeBase64Url(decoded) !== value) return false;
185
+ return decoded.length >= SHARE_TAG_BYTES && decoded.length <= MAX_SHARE_CIPHERTEXT_BYTES;
186
+ },
187
+ {
188
+ message: `must be canonical base64url decoding to ${SHARE_TAG_BYTES}..${MAX_SHARE_CIPHERTEXT_BYTES} bytes`,
189
+ }
190
+ );
191
+ export type ShareCiphertext = z.infer<typeof ShareCiphertextValidator>;
192
+
193
+ /**
194
+ * AES-256-GCM envelope. `v`/`alg` are literals so an unknown version or
195
+ * algorithm is rejected by the schema before any decryption is attempted.
196
+ */
197
+ export const ShareEnvelopeValidator = z
198
+ .object({
199
+ v: z.literal(1),
200
+ alg: z.literal('A256GCM'),
201
+ iv: ShareIvValidator,
202
+ ct: ShareCiphertextValidator,
203
+ })
204
+ .strict();
205
+ export type ShareEnvelope = z.infer<typeof ShareEnvelopeValidator>;
206
+
207
+ const safeVersion = z
208
+ .number()
209
+ .int()
210
+ .min(1)
211
+ .max(2 ** 31 - 1);
212
+ const safeCredentialIndex = z
213
+ .number()
214
+ .int()
215
+ .min(0)
216
+ .max(MAX_VP_MEMBERS - 1);
217
+
218
+ /** Index-only reference to a selected credential inside the signed VP. */
219
+ export const ShareSelectionEntryValidator = z
220
+ .object({ credentialIndex: safeCredentialIndex })
221
+ .strict();
222
+ export type ShareSelectionEntry = z.infer<typeof ShareSelectionEntryValidator>;
223
+
224
+ /**
225
+ * Index-only reference to an endorsement credential, plus the selected credential
226
+ * it endorses. Extra keys (for example a source `ref`/`uri`) are rejected so a
227
+ * recipient payload can never leak the owner's private source index.
228
+ */
229
+ export const ShareEndorsementEntryValidator = z
230
+ .object({ credentialIndex: safeCredentialIndex, targetCredentialIndex: safeCredentialIndex })
231
+ .strict();
232
+ export type ShareEndorsementEntry = z.infer<typeof ShareEndorsementEntryValidator>;
233
+
234
+ export const ShareSharerValidator = z
235
+ .object({
236
+ profileId: z.string().min(1).max(128),
237
+ displayName: z.string().min(1).max(120),
238
+ avatar: z.string().max(2048).optional(),
239
+ })
240
+ .strict();
241
+ export type ShareSharer = z.infer<typeof ShareSharerValidator>;
242
+
243
+ /**
244
+ * Holder-signed presentation carried by the manifest. `verifiableCredential` is a
245
+ * required non-empty array so the local indices are always resolvable. Individual
246
+ * credentials keep the existing permissive VC shape (`.catchall`) — no signed
247
+ * credential field is stripped or rewritten by validation.
248
+ */
249
+ const presentationShape = VPValidator.extend({
250
+ verifiableCredential: VCValidator.array().min(1).max(MAX_VP_MEMBERS),
251
+ });
252
+
253
+ /** Bound nested CLR credentials and JSON depth before schema traversal. */
254
+ const hasBoundedPresentation = (value: unknown): boolean => {
255
+ let credentialCount = 0;
256
+ const ancestors = new Set<object>();
257
+ const visit = (node: unknown, depth: number): boolean => {
258
+ if (node === null || typeof node !== 'object') return true;
259
+ if (depth > 32 || ancestors.has(node)) return false;
260
+ ancestors.add(node);
261
+ try {
262
+ for (const [key, child] of Object.entries(node)) {
263
+ if (key === 'verifiableCredential') {
264
+ credentialCount += Array.isArray(child) ? child.length : 1;
265
+ if (credentialCount > MAX_VP_MEMBERS) return false;
266
+ }
267
+ if (!visit(child, depth + 1)) return false;
268
+ }
269
+ return true;
270
+ } finally {
271
+ ancestors.delete(node);
272
+ }
273
+ };
274
+ return visit(value, 0);
275
+ };
276
+
277
+ // Validate without returning Zod's parsed copy: nested VC schemas may strip
278
+ // extension fields, which would change the signed document before verification.
279
+ export const ShareManifestPresentationValidator = z.custom<z.infer<typeof presentationShape>>(
280
+ value => hasBoundedPresentation(value) && presentationShape.safeParse(value).success,
281
+ { message: 'invalid share presentation' }
282
+ );
283
+ export type ShareManifestPresentation = z.infer<typeof ShareManifestPresentationValidator>;
284
+
285
+ export const SharePayloadValidatorShape = z
286
+ .object({
287
+ protocol: z.literal(SHARE_LINK_PROTOCOL),
288
+ shareId: ShareLinkIdValidator,
289
+ contentVersion: safeVersion,
290
+ createdAt: z.iso.datetime(),
291
+ sharer: ShareSharerValidator,
292
+ presentation: ShareManifestPresentationValidator,
293
+ selection: ShareSelectionEntryValidator.array().min(1).max(MAX_SELECTED_CREDENTIALS),
294
+ endorsements: ShareEndorsementEntryValidator.array().max(MAX_ENDORSEMENTS).default([]),
295
+ })
296
+ .strict();
297
+ export type SharePayloadShape = z.infer<typeof SharePayloadValidatorShape>;
298
+
299
+ /** Structured classification failure raised by {@link classifyShareManifest}. */
300
+ export type ShareManifestClassificationCode =
301
+ | 'NO_PRESENTATION_MEMBERS'
302
+ | 'TOO_MANY_VP_MEMBERS'
303
+ | 'BAD_SELECTION'
304
+ | 'BAD_ENDORSEMENTS'
305
+ | 'SELECTION_INDEX_OUT_OF_BOUNDS'
306
+ | 'SELECTION_INDEX_DUPLICATE'
307
+ | 'ENDORSEMENT_INDEX_OUT_OF_BOUNDS'
308
+ | 'VP_MEMBER_CLASSIFIED_TWICE'
309
+ | 'ENDORSEMENT_TARGET_NOT_SELECTED'
310
+ | 'ENDORSEMENT_TARGET_UNBOUND'
311
+ | 'UNCLASSIFIED_VP_MEMBER';
312
+
313
+ export interface ShareManifestClassification {
314
+ /** Ordered selected VP member indices, exactly as the manifest declares them. */
315
+ selectedIndices: number[];
316
+ /** Endorsement VP member indices (unordered classification helper). */
317
+ endorsementIndices: number[];
318
+ /** Total VP members, all of which are classified. */
319
+ memberCount: number;
320
+ }
321
+
322
+ export type ShareManifestClassificationResult =
323
+ | { ok: true; classification: ShareManifestClassification }
324
+ | {
325
+ ok: false;
326
+ code: ShareManifestClassificationCode;
327
+ message: string;
328
+ path: (string | number)[];
329
+ };
330
+
331
+ const classificationFailure = (
332
+ code: ShareManifestClassificationCode,
333
+ message: string,
334
+ path: (string | number)[] = []
335
+ ): ShareManifestClassificationResult => ({ ok: false, code, message, path });
336
+
337
+ /** Read a credential's self-declared target id from either endorsement claim shape. */
338
+ const readSignedClaimId = (member: unknown): unknown => {
339
+ if (member === null || typeof member !== 'object') return undefined;
340
+
341
+ const record = member as Record<string, unknown>;
342
+ const claim = record.credentialSubject ?? record.endorsement;
343
+ if (claim === null || typeof claim !== 'object') return undefined;
344
+
345
+ const claimRecord = claim as Record<string, unknown>;
346
+ const nested = claimRecord.endorsement;
347
+ const nestedId =
348
+ nested !== null && typeof nested === 'object'
349
+ ? (nested as Record<string, unknown>).id
350
+ : undefined;
351
+
352
+ return claimRecord.id ?? nestedId;
353
+ };
354
+
355
+ /**
356
+ * Structurally classify every holder-signed VP member exactly once.
357
+ *
358
+ * This is *structural* validation only: it proves each member is either selected
359
+ * or an endorsement of a selected member and that no member is smuggled. It does
360
+ * not verify any cryptographic proof. Endorsement target binding here reads the
361
+ * endorsement's own signed claim (`credentialSubject.id` / `endorsement.id`); it
362
+ * is still not signature verification.
363
+ */
364
+ export const classifyShareManifest = (
365
+ payload: Pick<SharePayloadShape, 'presentation' | 'selection' | 'endorsements'>
366
+ ): ShareManifestClassificationResult => {
367
+ const members = payload.presentation?.verifiableCredential;
368
+
369
+ if (!Array.isArray(members) || members.length === 0) {
370
+ return classificationFailure('NO_PRESENTATION_MEMBERS', 'presentation has no credentials');
371
+ }
372
+ if (members.length > MAX_VP_MEMBERS) {
373
+ return classificationFailure(
374
+ 'TOO_MANY_VP_MEMBERS',
375
+ `presentation has ${members.length} members, over the ${MAX_VP_MEMBERS} bound`
376
+ );
377
+ }
378
+
379
+ const selection = payload.selection;
380
+ const endorsements = payload.endorsements ?? [];
381
+
382
+ if (
383
+ !Array.isArray(selection) ||
384
+ selection.length < 1 ||
385
+ selection.length > MAX_SELECTED_CREDENTIALS
386
+ ) {
387
+ return classificationFailure('BAD_SELECTION', 'selection must contain 1..50 entries');
388
+ }
389
+ if (!Array.isArray(endorsements) || endorsements.length > MAX_ENDORSEMENTS) {
390
+ return classificationFailure(
391
+ 'BAD_ENDORSEMENTS',
392
+ 'endorsements must contain at most 200 entries'
393
+ );
394
+ }
395
+
396
+ const selectedIndices: number[] = [];
397
+ const selected = new Set<number>();
398
+
399
+ for (let index = 0; index < selection.length; index += 1) {
400
+ const entry = selection[index];
401
+ const credentialIndex = entry?.credentialIndex;
402
+
403
+ if (
404
+ !Number.isSafeInteger(credentialIndex) ||
405
+ credentialIndex < 0 ||
406
+ credentialIndex >= members.length
407
+ ) {
408
+ return classificationFailure(
409
+ 'SELECTION_INDEX_OUT_OF_BOUNDS',
410
+ `selection[${index}].credentialIndex is out of bounds`,
411
+ ['selection', index, 'credentialIndex']
412
+ );
413
+ }
414
+ if (selected.has(credentialIndex)) {
415
+ return classificationFailure(
416
+ 'SELECTION_INDEX_DUPLICATE',
417
+ `selection[${index}].credentialIndex is duplicated`,
418
+ ['selection', index, 'credentialIndex']
419
+ );
420
+ }
421
+
422
+ selected.add(credentialIndex);
423
+ selectedIndices.push(credentialIndex);
424
+ }
425
+
426
+ const classified = new Set<number>(selected);
427
+ const endorsementIndices: number[] = [];
428
+
429
+ for (let index = 0; index < endorsements.length; index += 1) {
430
+ const entry = endorsements[index];
431
+ const credentialIndex = entry?.credentialIndex;
432
+ const targetIndex = entry?.targetCredentialIndex;
433
+
434
+ if (
435
+ !Number.isSafeInteger(credentialIndex) ||
436
+ credentialIndex < 0 ||
437
+ credentialIndex >= members.length
438
+ ) {
439
+ return classificationFailure(
440
+ 'ENDORSEMENT_INDEX_OUT_OF_BOUNDS',
441
+ `endorsements[${index}].credentialIndex is out of bounds`,
442
+ ['endorsements', index, 'credentialIndex']
443
+ );
444
+ }
445
+ if (classified.has(credentialIndex)) {
446
+ return classificationFailure(
447
+ 'VP_MEMBER_CLASSIFIED_TWICE',
448
+ `endorsements[${index}].credentialIndex was already classified`,
449
+ ['endorsements', index, 'credentialIndex']
450
+ );
451
+ }
452
+ if (!selected.has(targetIndex)) {
453
+ return classificationFailure(
454
+ 'ENDORSEMENT_TARGET_NOT_SELECTED',
455
+ `endorsements[${index}].targetCredentialIndex must reference a selected credential`,
456
+ ['endorsements', index, 'targetCredentialIndex']
457
+ );
458
+ }
459
+
460
+ const referenced = readSignedClaimId(members[credentialIndex]);
461
+ const target = members[targetIndex];
462
+ const targetId: unknown =
463
+ target !== null && typeof target === 'object'
464
+ ? (target as Record<string, unknown>).id
465
+ : undefined;
466
+
467
+ if (
468
+ typeof referenced !== 'string' ||
469
+ typeof targetId !== 'string' ||
470
+ referenced !== targetId
471
+ ) {
472
+ return classificationFailure(
473
+ 'ENDORSEMENT_TARGET_UNBOUND',
474
+ `endorsements[${index}] claim does not reference the target credential id`,
475
+ ['endorsements', index]
476
+ );
477
+ }
478
+
479
+ classified.add(credentialIndex);
480
+ endorsementIndices.push(credentialIndex);
481
+ }
482
+
483
+ if (classified.size !== members.length) {
484
+ return classificationFailure(
485
+ 'UNCLASSIFIED_VP_MEMBER',
486
+ 'every presentation member must be classified exactly once',
487
+ ['presentation', 'verifiableCredential']
488
+ );
489
+ }
490
+
491
+ return {
492
+ ok: true,
493
+ classification: { selectedIndices, endorsementIndices, memberCount: members.length },
494
+ };
495
+ };
496
+
497
+ /**
498
+ * Manifest validator. Beyond the strict shape it enforces the full structural
499
+ * classification, so a parsed manifest is guaranteed to classify every VP member
500
+ * exactly once with in-bounds indices and claim-bound endorsement targets.
501
+ */
502
+ export const SharePayloadValidator = SharePayloadValidatorShape.superRefine((payload, ctx) => {
503
+ const result = classifyShareManifest(payload);
504
+ if (!result.ok) {
505
+ ctx.addIssue({
506
+ code: 'custom',
507
+ message: result.message,
508
+ path: result.path,
509
+ params: { shareLinkCode: result.code },
510
+ });
511
+ }
512
+ });
513
+ export type SharePayload = z.infer<typeof SharePayloadValidatorShape>;
514
+
515
+ /** Ordered selected source reference. Source URIs live only in owner recovery. */
516
+ export const ShareRecoverySelectionValidator = z
517
+ .object({
518
+ ref: z.string().min(1).max(MAX_SHARE_SOURCE_URI_CHARS),
519
+ order: z
520
+ .number()
521
+ .int()
522
+ .min(0)
523
+ .max(MAX_SELECTED_CREDENTIALS - 1),
524
+ })
525
+ .strict();
526
+ export type ShareRecoverySelection = z.infer<typeof ShareRecoverySelectionValidator>;
527
+
528
+ export const ShareRecoveryEndorsementValidator = z
529
+ .object({ targetRef: z.string().min(1).max(MAX_SHARE_SOURCE_URI_CHARS) })
530
+ .strict();
531
+ export type ShareRecoveryEndorsement = z.infer<typeof ShareRecoveryEndorsementValidator>;
532
+
533
+ export const ShareRecoveryPlaintextValidator = z
534
+ .object({
535
+ protocol: z.literal(SHARE_RECOVERY_PROTOCOL),
536
+ shareId: ShareLinkIdValidator,
537
+ ownerProfileId: z.string().min(1).max(128),
538
+ createdAt: z.iso.datetime(),
539
+ latest: z.object({ contentVersion: safeVersion, key: ShareContentKeyValidator }).strict(),
540
+ selection: ShareRecoverySelectionValidator.array().min(1).max(MAX_SELECTED_CREDENTIALS),
541
+ endorsements: ShareRecoveryEndorsementValidator.array().max(MAX_ENDORSEMENTS).default([]),
542
+ })
543
+ .strict()
544
+ .superRefine((recovery, ctx) => {
545
+ const orders = new Set<number>();
546
+
547
+ recovery.selection.forEach((entry, index) => {
548
+ if (entry.order >= recovery.selection.length) {
549
+ ctx.addIssue({
550
+ code: 'custom',
551
+ message: 'order must be within the selection bounds',
552
+ path: ['selection', index, 'order'],
553
+ });
554
+ }
555
+ if (orders.has(entry.order)) {
556
+ ctx.addIssue({
557
+ code: 'custom',
558
+ message: 'order must be unique within the selection',
559
+ path: ['selection', index, 'order'],
560
+ });
561
+ }
562
+ orders.add(entry.order);
563
+ });
564
+ });
565
+ export type ShareRecoveryPlaintext = z.infer<typeof ShareRecoveryPlaintextValidator>;
566
+
567
+ /**
568
+ * Owner-encrypted recovery JWE. The cap applies to the *serialized envelope*
569
+ * (UTF-8 of the JWE), not to the plaintext index — see Task A2 review item 7.
570
+ */
571
+ export const ShareOwnerRecoveryValidator = JWEValidator.refine(
572
+ value => utf8ByteLength(JSON.stringify(value)) <= MAX_SHARE_RECOVERY_JWE_BYTES,
573
+ { message: `serialized recovery JWE must be at most ${MAX_SHARE_RECOVERY_JWE_BYTES} bytes` }
574
+ );
575
+ export type ShareOwnerRecovery = z.infer<typeof ShareOwnerRecoveryValidator>;
576
+
577
+ export const ShareLinkStatusValidator = z.enum(['pending', 'active', 'stopped']);
578
+ export type ShareLinkStatus = z.infer<typeof ShareLinkStatusValidator>;
579
+
580
+ export const ShareContentStateValidator = z.enum(['staging', 'finalized', 'content_missing']);
581
+ export type ShareContentState = z.infer<typeof ShareContentStateValidator>;
582
+
583
+ /**
584
+ * Owner-facing share metadata. Never returned to unauthenticated callers; the
585
+ * public projection below omits age-policy and owner-private fields entirely.
586
+ */
587
+ export const ShareLinkValidator = z.object({
588
+ id: ShareLinkIdValidator,
589
+ title: z.string().min(1).max(120),
590
+ note: z.string().max(500).optional(),
591
+ selectedCount: z.number().int().min(1).max(MAX_SELECTED_CREDENTIALS),
592
+ version: safeVersion,
593
+ contentVersion: safeVersion,
594
+ status: ShareLinkStatusValidator,
595
+ contentState: ShareContentStateValidator,
596
+ createdAt: z.iso.datetime(),
597
+ updatedAt: z.iso.datetime(),
598
+ expiresAt: z.iso.datetime().nullable(),
599
+ stoppedAt: z.iso.datetime().nullable(),
600
+ lastViewedAt: z.iso.datetime().nullable(),
601
+ viewCount: z.number().int().min(0).optional(),
602
+ passcodeProtected: z.boolean(),
603
+ notifyOnView: z.boolean(),
604
+ minorPolicy: z.object({
605
+ isMinor: z.boolean().nullable(),
606
+ policyResolved: z.boolean(),
607
+ defaultExpiryDays: z.union([z.literal(30), z.literal(365)]),
608
+ viewCountingEnabled: z.boolean(),
609
+ }),
610
+ /**
611
+ * Guarded application/API URL for the content. Optional because owner APIs in
612
+ * D1 have no public resolve/content endpoint yet; it must never be a
613
+ * LearnCloud object URL or carry a fabricated decryption key.
614
+ */
615
+ contentUrl: z.string().optional(),
616
+ });
617
+ export type ShareLink = z.infer<typeof ShareLinkValidator>;
618
+
619
+ const clientRequestId = z.string().uuid();
620
+
621
+ export const CreateShareLinkInputValidator = z
622
+ .object({
623
+ id: ShareLinkIdValidator,
624
+ clientRequestId,
625
+ title: z.string().min(1).max(120),
626
+ note: z.string().max(500).optional(),
627
+ expiresAt: z.iso.datetime().nullable().optional(),
628
+ passcode: z.string().min(8).max(64).optional(),
629
+ notifyOnView: z.boolean().default(false),
630
+ selectedCount: z.number().int().min(1).max(MAX_SELECTED_CREDENTIALS),
631
+ contentVersion: z.literal(1),
632
+ envelope: ShareEnvelopeValidator,
633
+ ownerEncryptedRecovery: ShareOwnerRecoveryValidator,
634
+ })
635
+ .strict();
636
+ export type CreateShareLinkInput = z.infer<typeof CreateShareLinkInputValidator>;
637
+
638
+ /**
639
+ * Dependent fields: a content replacement supplies `contentVersion`,
640
+ * `selectedCount`, `envelope` and `ownerEncryptedRecovery` together, or none of
641
+ * them. A metadata-only update must not touch content.
642
+ */
643
+ export const UpdateShareLinkInputValidator = z
644
+ .object({
645
+ id: ShareLinkIdValidator,
646
+ expectedVersion: safeVersion,
647
+ clientRequestId,
648
+ title: z.string().min(1).max(120).optional(),
649
+ note: z.string().max(500).nullable().optional(),
650
+ expiresAt: z.iso.datetime().nullable().optional(),
651
+ /** Omitted preserves the current passcode, null removes it, and a string replaces it. */
652
+ passcode: z.string().min(8).max(64).nullable().optional(),
653
+ notifyOnView: z.boolean().optional(),
654
+ contentVersion: safeVersion.optional(),
655
+ selectedCount: z.number().int().min(1).max(MAX_SELECTED_CREDENTIALS).optional(),
656
+ envelope: ShareEnvelopeValidator.optional(),
657
+ ownerEncryptedRecovery: ShareOwnerRecoveryValidator.optional(),
658
+ })
659
+ .strict()
660
+ .superRefine((value, ctx) => {
661
+ const provided = [
662
+ value.contentVersion,
663
+ value.selectedCount,
664
+ value.envelope,
665
+ value.ownerEncryptedRecovery,
666
+ ].filter(field => field !== undefined).length;
667
+
668
+ if (provided !== 0 && provided !== 4) {
669
+ ctx.addIssue({
670
+ code: 'custom',
671
+ message:
672
+ 'content replacement requires contentVersion, selectedCount, envelope and ownerEncryptedRecovery together',
673
+ });
674
+ }
675
+ });
676
+ export type UpdateShareLinkInput = z.infer<typeof UpdateShareLinkInputValidator>;
677
+
678
+ export const ShareLinkIdInputValidator = z.object({ id: ShareLinkIdValidator }).strict();
679
+ export type ShareLinkIdInput = z.infer<typeof ShareLinkIdInputValidator>;
680
+
681
+ /**
682
+ * Owner-scoped retry key. Only the opaque share id and the caller operation id
683
+ * are accepted; the server re-derives every immutable binding (object ref,
684
+ * generation, lease, hashes) from persisted Brain state under the owner's own
685
+ * namespace. This is never a reservation descriptor.
686
+ */
687
+ export const ShareLinkOperationKeyInputValidator = z
688
+ .object({ id: ShareLinkIdValidator, operationId: z.string().uuid() })
689
+ .strict();
690
+ export type ShareLinkOperationKeyInput = z.infer<typeof ShareLinkOperationKeyInputValidator>;
691
+
692
+ /**
693
+ * Sanitized owner mutation output. A pending result exposes only the retry key.
694
+ *
695
+ * `completed` means the request reached a recorded terminal outcome; the
696
+ * authoritative share state (`share.status`, e.g. `stopped` after revoke) is
697
+ * carried inside `share` and must never be overwritten by the outer tag.
698
+ */
699
+ export const ShareLinkOwnerCommitOutputValidator = z.discriminatedUnion('status', [
700
+ z.object({ status: z.literal('completed'), share: ShareLinkValidator }).strict(),
701
+ z
702
+ .object({
703
+ status: z.literal('pending'),
704
+ id: ShareLinkIdValidator,
705
+ operationId: z.string().uuid(),
706
+ })
707
+ .strict(),
708
+ ]);
709
+ export type ShareLinkOwnerCommitOutput = z.infer<typeof ShareLinkOwnerCommitOutputValidator>;
710
+
711
+ /**
712
+ * Sanitized owner status output; never a raw coordinator record. `found` means a
713
+ * share snapshot was returned and `share.status` is authoritative (active or
714
+ * stopped); it does not itself assert the share is active.
715
+ */
716
+ export const ShareLinkOwnerStatusOutputValidator = z.discriminatedUnion('status', [
717
+ z.object({ status: z.literal('found'), share: ShareLinkValidator }).strict(),
718
+ z
719
+ .object({
720
+ status: z.literal('pending'),
721
+ id: ShareLinkIdValidator,
722
+ operationId: z.string().uuid(),
723
+ })
724
+ .strict(),
725
+ z.object({ status: z.literal('not_found'), id: ShareLinkIdValidator }).strict(),
726
+ ]);
727
+ export type ShareLinkOwnerStatusOutput = z.infer<typeof ShareLinkOwnerStatusOutputValidator>;
728
+
729
+ /** Owner-only recovery response: the owner-encrypted JWE and nothing else. */
730
+ export const ShareLinkOwnerRecoveryOutputValidator = z
731
+ .object({ recovery: ShareOwnerRecoveryValidator })
732
+ .strict();
733
+ export type ShareLinkOwnerRecoveryOutput = z.infer<typeof ShareLinkOwnerRecoveryOutputValidator>;
734
+
735
+ /** Authenticated owner-only ciphertext response; never includes a view receipt or storage refs. */
736
+ export const ShareLinkOwnerContentOutputValidator = z
737
+ .object({
738
+ id: ShareLinkIdValidator,
739
+ contentVersion: safeVersion,
740
+ envelope: ShareEnvelopeValidator,
741
+ })
742
+ .strict();
743
+ export type ShareLinkOwnerContentOutput = z.infer<typeof ShareLinkOwnerContentOutputValidator>;
744
+
745
+ export const ResolveShareLinkInputValidator = z
746
+ .object({
747
+ id: ShareLinkIdValidator,
748
+ /** Sent only in a POST body; it must never be placed in a share URL. */
749
+ passcode: z.string().min(4).max(64).optional(),
750
+ })
751
+ .strict();
752
+ export type ResolveShareLinkInput = z.infer<typeof ResolveShareLinkInputValidator>;
753
+
754
+ /** Public state. No `isMinor`, `policyResolved`, thresholds or count policy. */
755
+ export const ShareLinkPublicStateValidator = z.discriminatedUnion('state', [
756
+ z
757
+ .object({
758
+ state: z.literal('passcode_required'),
759
+ id: ShareLinkIdValidator,
760
+ })
761
+ .strict(),
762
+ z.object({ state: z.literal('try_later'), id: ShareLinkIdValidator }).strict(),
763
+ z
764
+ .object({
765
+ state: z.literal('active'),
766
+ id: ShareLinkIdValidator,
767
+ title: z.string().max(120),
768
+ note: z.string().max(500).optional(),
769
+ selectedCount: z.number().int().min(1).max(MAX_SELECTED_CREDENTIALS),
770
+ contentVersion: safeVersion,
771
+ contentUrl: z.string(),
772
+ sharer: z.object({
773
+ displayName: z.string().max(120),
774
+ avatar: z.string().max(2048).optional(),
775
+ }),
776
+ createdAt: z.iso.datetime(),
777
+ updatedAt: z.iso.datetime(),
778
+ expiresAt: z.iso.datetime().nullable(),
779
+ })
780
+ .strict(),
781
+ z
782
+ .object({
783
+ state: z.literal('expired'),
784
+ id: ShareLinkIdValidator,
785
+ expiresAt: z.iso.datetime(),
786
+ })
787
+ .strict(),
788
+ z
789
+ .object({
790
+ state: z.literal('stopped'),
791
+ id: ShareLinkIdValidator,
792
+ stoppedAt: z.iso.datetime(),
793
+ })
794
+ .strict(),
795
+ z.object({ state: z.literal('not_found'), id: ShareLinkIdValidator }).strict(),
796
+ ]);
797
+ export type ShareLinkPublicState = z.infer<typeof ShareLinkPublicStateValidator>;
798
+
799
+ export const ListShareLinksInputValidator = z
800
+ .object({
801
+ limit: z.number().int().min(1).max(50).default(25),
802
+ cursor: z.string().max(512).optional(),
803
+ })
804
+ .strict();
805
+ export type ListShareLinksInput = z.infer<typeof ListShareLinksInputValidator>;
806
+
807
+ export const PaginatedShareLinksValidator = PaginationResponseValidator.extend({
808
+ records: ShareLinkValidator.array(),
809
+ });
810
+ export type PaginatedShareLinks = z.infer<typeof PaginatedShareLinksValidator>;
811
+
812
+ export const AcknowledgeViewInputValidator = z
813
+ .object({
814
+ receipt: z
815
+ .string()
816
+ .min(22)
817
+ .max(128)
818
+ .regex(/^[A-Za-z0-9_-]+$/),
819
+ })
820
+ .strict();
821
+ export type AcknowledgeViewInput = z.infer<typeof AcknowledgeViewInputValidator>;
822
+
823
+ /** Uniform response: never reveal eligibility or whether a receipt incremented a count. */
824
+ export const AcknowledgeViewOutputValidator = z.object({ ok: z.literal(true) }).strict();
825
+ export type AcknowledgeViewOutput = z.infer<typeof AcknowledgeViewOutputValidator>;
826
+
827
+ /**
828
+ * Fixed short TTL for a persisted view receipt. Acknowledgement binds the
829
+ * receipt to one committed `(namespace, share, owner, contentVersion, object,
830
+ * operation)` tuple; the node is pruned after this window (or once consumed).
831
+ */
832
+ export const SHARE_VIEW_RECEIPT_TTL_SECONDS = 10 * 60;
833
+
834
+ /**
835
+ * Public content response. The envelope and `contentVersion` come from one
836
+ * committed tuple; no object ref, hash, operation id, owner recovery, owner id
837
+ * or policy field is ever returned. `receipt` is ALWAYS present with identical
838
+ * shape/entropy whether or not the owner is eligible for counting: ineligible,
839
+ * unknown, managed, minor or dependency-failure responses carry a CSPRNG
840
+ * padding value that is never persisted, so the response is not an eligibility
841
+ * oracle.
842
+ */
843
+ export const ShareLinkPublicContentViewValidator = z
844
+ .object({
845
+ id: ShareLinkIdValidator,
846
+ contentVersion: safeVersion,
847
+ envelope: ShareEnvelopeValidator,
848
+ contentUrl: z.string(),
849
+ receipt: z
850
+ .string()
851
+ .min(22)
852
+ .max(128)
853
+ .regex(/^[A-Za-z0-9_-]+$/),
854
+ })
855
+ .strict();
856
+ export type ShareLinkPublicContentView = z.infer<typeof ShareLinkPublicContentViewValidator>;