@serve.zone/interfaces 22.0.0 → 23.0.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.
Files changed (75) hide show
  1. package/changelog.md +12 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/appstore/types.d.ts +31 -16
  4. package/dist_ts/appstore/types.js +208 -11
  5. package/dist_ts/data/deploymentoperation.js +6 -20
  6. package/dist_ts/data/deploymentpreflight.d.ts +2 -6
  7. package/dist_ts/data/deploymentpreflight.js +1 -1
  8. package/dist_ts/data/hostedapp.d.ts +40 -14
  9. package/dist_ts/data/hostedapp.js +90 -1
  10. package/dist_ts/data/immutableimage.d.ts +30 -1
  11. package/dist_ts/data/immutableimage.js +103 -1
  12. package/dist_ts/data/index.d.ts +0 -2
  13. package/dist_ts/data/index.js +1 -3
  14. package/dist_ts/data/secret.d.ts +186 -13
  15. package/dist_ts/data/secret.js +543 -45
  16. package/dist_ts/data/service.d.ts +0 -39
  17. package/dist_ts/data/service.js +1 -77
  18. package/dist_ts/data/settings.d.ts +62 -43
  19. package/dist_ts/data/settings.js +34 -2
  20. package/dist_ts/platform/storage.d.ts +13 -14
  21. package/dist_ts/platform/storage.js +1 -1
  22. package/dist_ts/platform/storagemigration.d.ts +8 -8
  23. package/dist_ts/platform/storagemigration.golden.d.ts +2 -2
  24. package/dist_ts/platform/storagemigration.golden.js +19 -88
  25. package/dist_ts/platform/storagemigration.js +46 -58
  26. package/dist_ts/platform/types.d.ts +5 -19
  27. package/dist_ts/plugins.d.ts +2 -1
  28. package/dist_ts/plugins.js +3 -2
  29. package/dist_ts/requests/hostedapp.d.ts +12 -9
  30. package/dist_ts/requests/hostedapp.js +38 -1
  31. package/dist_ts/requests/index.d.ts +1 -3
  32. package/dist_ts/requests/index.js +2 -4
  33. package/dist_ts/requests/platform.d.ts +0 -1
  34. package/dist_ts/requests/secret.d.ts +23 -17
  35. package/dist_ts/requests/secret.js +161 -2
  36. package/dist_ts/requests/service.d.ts +0 -13
  37. package/dist_ts/requests/settings.d.ts +3 -3
  38. package/dist_ts/runtime.d.ts +78 -26
  39. package/dist_ts/runtime.js +219 -19
  40. package/package.json +2 -1
  41. package/readme.md +106 -28
  42. package/ts/00_commitinfo_data.ts +1 -1
  43. package/ts/appstore/types.ts +251 -25
  44. package/ts/data/deploymentoperation.ts +7 -31
  45. package/ts/data/deploymentpreflight.ts +2 -5
  46. package/ts/data/hostedapp.ts +144 -18
  47. package/ts/data/immutableimage.ts +150 -1
  48. package/ts/data/index.ts +0 -2
  49. package/ts/data/secret.ts +775 -53
  50. package/ts/data/service.ts +0 -118
  51. package/ts/data/settings.ts +70 -80
  52. package/ts/platform/storage.ts +15 -16
  53. package/ts/platform/storagemigration.golden.ts +18 -95
  54. package/ts/platform/storagemigration.ts +57 -87
  55. package/ts/platform/types.ts +6 -19
  56. package/ts/plugins.ts +2 -0
  57. package/ts/requests/hostedapp.ts +50 -9
  58. package/ts/requests/index.ts +0 -4
  59. package/ts/requests/platform.ts +0 -1
  60. package/ts/requests/secret.ts +204 -9
  61. package/ts/requests/service.ts +0 -16
  62. package/ts/requests/settings.ts +3 -3
  63. package/ts/runtime.ts +314 -41
  64. package/dist_ts/data/secretbundle.d.ts +0 -57
  65. package/dist_ts/data/secretbundle.js +0 -2
  66. package/dist_ts/data/secretgroup.d.ts +0 -52
  67. package/dist_ts/data/secretgroup.js +0 -2
  68. package/dist_ts/requests/secretbundle.d.ts +0 -75
  69. package/dist_ts/requests/secretbundle.js +0 -4
  70. package/dist_ts/requests/secretgroup.d.ts +0 -52
  71. package/dist_ts/requests/secretgroup.js +0 -4
  72. package/ts/data/secretbundle.ts +0 -65
  73. package/ts/data/secretgroup.ts +0 -58
  74. package/ts/requests/secretbundle.ts +0 -103
  75. package/ts/requests/secretgroup.ts +0 -74
package/ts/data/secret.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as plugins from '../plugins.js';
1
2
  import type { TSha256Digest } from './immutableimage.js';
2
3
  import { normalizeServiceAbsolutePath } from './service.js';
3
4
 
@@ -18,11 +19,12 @@ export type TSecretVersionLifecycleState =
18
19
  /** Retired versions remain usable only through an already-accepted manifest. */
19
20
  export const isSecretVersionMaterialIssuable = (
20
21
  lifecycleStateArg: TSecretVersionLifecycleState,
21
- ): boolean => lifecycleStateArg === 'active' || lifecycleStateArg === 'retired';
22
+ referencedByAcceptedManifestArg: boolean,
23
+ ): boolean => lifecycleStateArg === 'active'
24
+ || (lifecycleStateArg === 'retired' && referencedByAcceptedManifestArg);
22
25
 
23
26
  export type TSecretManagementSource =
24
27
  | 'operator'
25
- | 'legacy'
26
28
  | 'appstore'
27
29
  | 'mail'
28
30
  | 'oidc'
@@ -32,7 +34,6 @@ export type TSecretManagementSource =
32
34
 
33
35
  export type TSecretManagementScope =
34
36
  | 'operator'
35
- | 'legacy'
36
37
  | `appstore:${string}`
37
38
  | `mail:${string}`
38
39
  | `oidc:${string}`
@@ -50,7 +51,24 @@ export interface ISecretSetSecretOwner {
50
51
  secretSetId: string;
51
52
  }
52
53
 
53
- export type TSecretOwner = IServiceSecretOwner | ISecretSetSecretOwner;
54
+ export interface IPlatformProviderSecretOwner {
55
+ kind: 'platform-provider';
56
+ providerConfigId: string;
57
+ }
58
+
59
+ export interface ISystemSecretOwner {
60
+ kind: 'system';
61
+ systemId: string;
62
+ }
63
+
64
+ export type TSecretOwner =
65
+ | IServiceSecretOwner
66
+ | ISecretSetSecretOwner
67
+ | IPlatformProviderSecretOwner
68
+ | ISystemSecretOwner;
69
+
70
+ export type TSecretMutationTarget = TSecretOwner;
71
+ export type TWorkloadSecretOwner = IServiceSecretOwner | ISecretSetSecretOwner;
54
72
 
55
73
  export interface ISecretVersionReference {
56
74
  secretId: string;
@@ -146,9 +164,12 @@ export interface ISecretSetConsumerRolloutStatus {
146
164
  updatedAt: number;
147
165
  }
148
166
 
149
- export interface ISecretEnvironmentDelivery {
150
- type: 'environment';
167
+ export interface ISecretLauncherEnvironmentDelivery {
168
+ type: 'launcher-environment';
151
169
  variableName: string;
170
+ uid: number;
171
+ gid: number;
172
+ mode: 0o400 | 0o440;
152
173
  }
153
174
 
154
175
  export interface ISecretFileDelivery {
@@ -156,10 +177,29 @@ export interface ISecretFileDelivery {
156
177
  targetPath: string;
157
178
  uid: number;
158
179
  gid: number;
159
- mode: number;
180
+ mode: 0o400 | 0o440;
160
181
  }
161
182
 
162
- export type TSecretDelivery = ISecretEnvironmentDelivery | ISecretFileDelivery;
183
+ export type TSecretDelivery = ISecretLauncherEnvironmentDelivery | ISecretFileDelivery;
184
+
185
+ export const secretValueLimits = Object.freeze({
186
+ maximumBytes: 500 * 1024,
187
+ minimumGeneratedBytes: 16,
188
+ maximumGeneratedBytes: 64,
189
+ });
190
+
191
+ export type TSecretGeneratedEncoding = 'hex' | 'base64url';
192
+
193
+ export type TSecretValueInput =
194
+ | {
195
+ mode: 'sealed';
196
+ envelope: plugins.smartcrypto.IX25519EnvelopeV1;
197
+ }
198
+ | {
199
+ mode: 'generated';
200
+ encoding: TSecretGeneratedEncoding;
201
+ bytes: number;
202
+ };
163
203
 
164
204
  export interface ISecretSetAttachmentMapping {
165
205
  /** Source key in the attached SecretSet. */
@@ -182,7 +222,7 @@ export interface ISecretSetAttachment {
182
222
  * Cloudly verifies organization ownership when resolving the referenced sets.
183
223
  */
184
224
  export interface IServiceSecretConfiguration {
185
- schemaVersion: 1;
225
+ schemaVersion: 2;
186
226
  /** Dedicated CAS revision for SecretSet attachments. */
187
227
  revision: number;
188
228
  /** Dedicated CAS revision for service-owned secret membership. */
@@ -195,18 +235,22 @@ export interface IResolvedSecretManifestEntry {
195
235
  secretVersionId: string;
196
236
  key: string;
197
237
  environment: TSecretEnvironment;
198
- owner: TSecretOwner;
238
+ owner: TWorkloadSecretOwner;
199
239
  delivery: TSecretDelivery;
200
240
  }
201
241
 
202
242
  /** Immutable, value-free runtime snapshot. */
203
243
  export interface IResolvedSecretManifest {
204
- schemaVersion: 1;
244
+ schemaVersion: 2;
205
245
  id: string;
206
246
  digest: TSha256Digest;
207
247
  organizationId: string;
208
248
  serviceId: string;
209
249
  clusterId: string;
250
+ imageRolloutId: string;
251
+ imageRolloutGeneration: number;
252
+ requestedImageDigest: TSha256Digest;
253
+ invocationDigest: TSha256Digest;
210
254
  secretRolloutId: string;
211
255
  secretRolloutGeneration: number;
212
256
  entries: IResolvedSecretManifestEntry[];
@@ -217,6 +261,10 @@ export interface IResolvedSecretManifestReference {
217
261
  manifestId: string;
218
262
  manifestDigest: TSha256Digest;
219
263
  clusterId: string;
264
+ imageRolloutId: string;
265
+ imageRolloutGeneration: number;
266
+ requestedImageDigest: TSha256Digest;
267
+ invocationDigest: TSha256Digest;
220
268
  secretRolloutId: string;
221
269
  secretRolloutGeneration: number;
222
270
  }
@@ -245,7 +293,7 @@ export interface IClusterSecretDeploymentState {
245
293
 
246
294
  /** Server-managed per-cluster authority switched with an expected revision CAS. */
247
295
  export interface IServiceSecretDeploymentPlan {
248
- schemaVersion: 1;
296
+ schemaVersion: 2;
249
297
  revision: number;
250
298
  clusterStates: IClusterSecretDeploymentState[];
251
299
  updatedAt: number;
@@ -260,6 +308,10 @@ export const resolvedSecretManifestReferencesEqual = (
260
308
  && leftArg?.manifestId === rightArg?.manifestId
261
309
  && leftArg?.manifestDigest === rightArg?.manifestDigest
262
310
  && leftArg?.clusterId === rightArg?.clusterId
311
+ && leftArg?.imageRolloutId === rightArg?.imageRolloutId
312
+ && leftArg?.imageRolloutGeneration === rightArg?.imageRolloutGeneration
313
+ && leftArg?.requestedImageDigest === rightArg?.requestedImageDigest
314
+ && leftArg?.invocationDigest === rightArg?.invocationDigest
263
315
  && leftArg?.secretRolloutId === rightArg?.secretRolloutId
264
316
  && leftArg?.secretRolloutGeneration === rightArg?.secretRolloutGeneration
265
317
  );
@@ -292,6 +344,20 @@ const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => (
292
344
  Boolean(valueArg) && typeof valueArg === 'object' && !Array.isArray(valueArg)
293
345
  );
294
346
 
347
+ const hasExactKeys = (
348
+ valueArg: Record<string, unknown>,
349
+ keysArg: string[],
350
+ ): boolean => {
351
+ const actual = Object.keys(valueArg).sort();
352
+ const expected = [...keysArg].sort();
353
+ return JSON.stringify(actual) === JSON.stringify(expected);
354
+ };
355
+
356
+ const hasOnlyKeys = (
357
+ valueArg: Record<string, unknown>,
358
+ keysArg: string[],
359
+ ): boolean => Object.keys(valueArg).every((keyArg) => keysArg.includes(keyArg));
360
+
295
361
  const isSecretIdentifier = (valueArg: unknown): valueArg is string => (
296
362
  typeof valueArg === 'string' && secretIdentifierRegex.test(valueArg)
297
363
  );
@@ -304,6 +370,30 @@ const isSecretEnvironment = (valueArg: unknown): valueArg is string => (
304
370
  typeof valueArg === 'string' && secretEnvironmentRegex.test(valueArg)
305
371
  );
306
372
 
373
+ export const validateSecretMutationId = (mutationIdArg: unknown): string[] => (
374
+ isSecretIdentifier(mutationIdArg)
375
+ ? []
376
+ : ['mutationId must be a canonical identifier']
377
+ );
378
+
379
+ export const validateSecretOwner = (ownerArg: unknown): string[] => {
380
+ if (!isRecord(ownerArg)) return ['secret owner must be an object'];
381
+ const shapeByKind = {
382
+ service: ['kind', 'serviceId'],
383
+ 'secret-set': ['kind', 'secretSetId'],
384
+ 'platform-provider': ['kind', 'providerConfigId'],
385
+ system: ['kind', 'systemId'],
386
+ } as const;
387
+ const keys = shapeByKind[ownerArg.kind as keyof typeof shapeByKind];
388
+ if (!keys || !hasExactKeys(ownerArg, [...keys])) {
389
+ return ['secret owner must use an exact supported owner schema'];
390
+ }
391
+ const idField = keys[1];
392
+ return isSecretIdentifier(ownerArg[idField])
393
+ ? []
394
+ : [`secret owner ${idField} must be canonical`];
395
+ };
396
+
307
397
  export const validateSecretManagementScope = (
308
398
  sourceArg: TSecretManagementSource,
309
399
  scopeArg: unknown,
@@ -313,11 +403,6 @@ export const validateSecretManagementScope = (
313
403
  ? []
314
404
  : ['operator management scope requires operator source'];
315
405
  }
316
- if (scopeArg === 'legacy') {
317
- return sourceArg === 'legacy'
318
- ? []
319
- : ['legacy management scope requires legacy source'];
320
- }
321
406
  if (typeof scopeArg !== 'string' || !secretManagementScopeRegex.test(scopeArg)) {
322
407
  return ['management scope must be canonical'];
323
408
  }
@@ -328,19 +413,23 @@ export const validateSecretManagementScope = (
328
413
 
329
414
  export const validateSecretDelivery = (deliveryArg: unknown): string[] => {
330
415
  if (!isRecord(deliveryArg)) return ['delivery must be an object'];
331
- if (deliveryArg.type === 'environment') {
332
- return isSecretKey(deliveryArg.variableName)
333
- ? []
334
- : ['environment delivery variableName must be a canonical environment key'];
416
+ const isLauncher = deliveryArg.type === 'launcher-environment';
417
+ if (!isLauncher && deliveryArg.type !== 'file') {
418
+ return ['delivery type must be launcher-environment or file'];
335
419
  }
336
- if (deliveryArg.type !== 'file') {
337
- return ['delivery type must be environment or file'];
420
+ const expectedKeys = isLauncher
421
+ ? ['type', 'variableName', 'uid', 'gid', 'mode']
422
+ : ['type', 'targetPath', 'uid', 'gid', 'mode'];
423
+ if (!hasExactKeys(deliveryArg, expectedKeys)) {
424
+ return ['delivery must use its exact schema'];
338
425
  }
339
426
  const errors: string[] = [];
340
- if (typeof deliveryArg.targetPath !== 'string'
427
+ if (isLauncher && !isSecretKey(deliveryArg.variableName)) {
428
+ errors.push('launcher delivery variableName must be a canonical environment key');
429
+ }
430
+ if (!isLauncher && (typeof deliveryArg.targetPath !== 'string'
341
431
  || normalizeServiceAbsolutePath(deliveryArg.targetPath) !== deliveryArg.targetPath
342
- || !/^\/run\/secrets\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(deliveryArg.targetPath)
343
- || deliveryArg.targetPath === '/run/secrets/secret.json') {
432
+ || !/^\/run\/secrets\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(deliveryArg.targetPath))) {
344
433
  errors.push('file delivery targetPath must be a canonical file below /run/secrets');
345
434
  }
346
435
  for (const field of ['uid', 'gid'] as const) {
@@ -348,15 +437,97 @@ export const validateSecretDelivery = (deliveryArg: unknown): string[] => {
348
437
  if (!Number.isSafeInteger(value)
349
438
  || (value as number) < 0
350
439
  || (value as number) > 2_147_483_647) {
351
- errors.push(`file delivery ${field} must be a bounded numeric identity`);
440
+ errors.push(`${isLauncher ? 'launcher' : 'file'} delivery ${field} must be a bounded numeric identity`);
352
441
  }
353
442
  }
354
443
  if (deliveryArg.mode !== 0o400 && deliveryArg.mode !== 0o440) {
355
- errors.push('file delivery mode must be 0400 or 0440');
444
+ errors.push(`${isLauncher ? 'launcher' : 'file'} delivery mode must be 0400 or 0440`);
356
445
  }
357
446
  return errors;
358
447
  };
359
448
 
449
+ export const validateSecretValueInput = (valueInputArg: unknown): string[] => {
450
+ if (!isRecord(valueInputArg)) return ['valueInput must be an object'];
451
+ if (valueInputArg.mode === 'sealed') {
452
+ if (!hasExactKeys(valueInputArg, ['mode', 'envelope'])) {
453
+ return ['sealed valueInput must use its exact schema'];
454
+ }
455
+ try {
456
+ const envelope = plugins.smartcrypto.parseX25519Envelope(valueInputArg.envelope);
457
+ return envelope.ciphertext.length
458
+ <= Math.ceil(secretValueLimits.maximumBytes * 4 / 3)
459
+ ? []
460
+ : [`sealed secret material must not exceed ${secretValueLimits.maximumBytes} bytes`];
461
+ } catch {
462
+ return ['sealed valueInput envelope must be a strict X25519 envelope'];
463
+ }
464
+ }
465
+ if (valueInputArg.mode === 'generated') {
466
+ if (!hasExactKeys(valueInputArg, ['mode', 'encoding', 'bytes'])) {
467
+ return ['generated valueInput must use its exact schema'];
468
+ }
469
+ const errors: string[] = [];
470
+ if (valueInputArg.encoding !== 'hex' && valueInputArg.encoding !== 'base64url') {
471
+ errors.push('generated valueInput encoding must be hex or base64url');
472
+ }
473
+ if (!Number.isSafeInteger(valueInputArg.bytes)
474
+ || (valueInputArg.bytes as number) < secretValueLimits.minimumGeneratedBytes
475
+ || (valueInputArg.bytes as number) > secretValueLimits.maximumGeneratedBytes) {
476
+ errors.push('generated valueInput bytes are outside the supported bounds');
477
+ }
478
+ return errors;
479
+ }
480
+ return ['valueInput mode must be sealed or generated'];
481
+ };
482
+
483
+ const encodeBase64Url = (bytesArg: Uint8Array): string => {
484
+ let binary = '';
485
+ for (const byte of bytesArg) binary += String.fromCharCode(byte);
486
+ return globalThis.btoa(binary)
487
+ .replace(/\+/g, '-')
488
+ .replace(/\//g, '_')
489
+ .replace(/=+$/, '');
490
+ };
491
+
492
+ /** Checks the SmartCrypto context digest without opening or decrypting the envelope. */
493
+ export const verifySecretEnvelopeContext = async (
494
+ envelopeArg: unknown,
495
+ contextArg: unknown,
496
+ ): Promise<boolean> => {
497
+ if (!(contextArg instanceof Uint8Array)) return false;
498
+ let envelope: plugins.smartcrypto.IX25519EnvelopeV1;
499
+ try {
500
+ envelope = plugins.smartcrypto.parseX25519Envelope(envelopeArg);
501
+ } catch {
502
+ return false;
503
+ }
504
+ const contextDigest = new Uint8Array(await globalThis.crypto.subtle.digest(
505
+ 'SHA-256',
506
+ new Uint8Array(contextArg),
507
+ ));
508
+ return envelope.contextDigest === encodeBase64Url(contextDigest);
509
+ };
510
+
511
+ /** Files accept arbitrary bytes; launcher values must be valid UTF-8 without NUL. */
512
+ export const validateSecretValueBytes = (
513
+ valueBytesArg: unknown,
514
+ deliveryArg: TSecretDelivery,
515
+ ): string[] => {
516
+ if (!(valueBytesArg instanceof Uint8Array)) return ['secret material must be bytes'];
517
+ if (valueBytesArg.byteLength > secretValueLimits.maximumBytes) {
518
+ return [`secret material must not exceed ${secretValueLimits.maximumBytes} bytes`];
519
+ }
520
+ if (deliveryArg.type === 'file') return [];
521
+ try {
522
+ const decoded = new TextDecoder('utf-8', { fatal: true }).decode(valueBytesArg);
523
+ return decoded.includes('\0')
524
+ ? ['launcher secret material must not contain NUL']
525
+ : [];
526
+ } catch {
527
+ return ['launcher secret material must be valid UTF-8'];
528
+ }
529
+ };
530
+
360
531
  /**
361
532
  * One shared precedence rule for resolution and runtime verification.
362
533
  * A manifest is authoritative once published; before publication an attachment
@@ -378,16 +549,26 @@ export const validateServiceSecretConfiguration = (
378
549
  if (!isRecord(configurationArg)) {
379
550
  return ['secretConfiguration must be an object'];
380
551
  }
381
- if (configurationArg.schemaVersion !== 1) {
382
- return ['secretConfiguration schemaVersion must be 1'];
552
+ const errors: string[] = [];
553
+ if (!hasExactKeys(configurationArg, [
554
+ 'schemaVersion',
555
+ 'revision',
556
+ 'secretsRevision',
557
+ 'secretSetAttachments',
558
+ ])) {
559
+ errors.push('secretConfiguration must use its exact schema');
560
+ }
561
+ if (configurationArg.schemaVersion !== 2) {
562
+ errors.push('secretConfiguration schemaVersion must be 2');
383
563
  }
384
564
  if (!Array.isArray(configurationArg.secretSetAttachments)) {
385
- return ['secretSetAttachments must be an array'];
565
+ errors.push('secretSetAttachments must be an array');
566
+ return errors;
386
567
  }
387
568
  if (configurationArg.secretSetAttachments.length > 64) {
388
- return ['secretSetAttachments must contain at most 64 entries'];
569
+ errors.push('secretSetAttachments must contain at most 64 entries');
570
+ return errors;
389
571
  }
390
- const errors: string[] = [];
391
572
  if (!Number.isSafeInteger(configurationArg.revision)
392
573
  || (configurationArg.revision as number) < 0) {
393
574
  errors.push('secretConfiguration revision must be a non-negative integer');
@@ -403,6 +584,9 @@ export const validateServiceSecretConfiguration = (
403
584
  errors.push(`secretSetAttachments[${attachmentIndex}] must be an object`);
404
585
  continue;
405
586
  }
587
+ if (!hasOnlyKeys(candidate, ['id', 'secretSetId', 'environment', 'mappings'])) {
588
+ errors.push(`secretSetAttachments[${attachmentIndex}] contains unsupported fields`);
589
+ }
406
590
  if (!isSecretIdentifier(candidate.id)) {
407
591
  errors.push(`secretSetAttachments[${attachmentIndex}].id must be a canonical identifier`);
408
592
  } else {
@@ -436,6 +620,11 @@ export const validateServiceSecretConfiguration = (
436
620
  );
437
621
  continue;
438
622
  }
623
+ if (!hasOnlyKeys(mappingCandidate, ['sourceKey', 'targetKey', 'delivery'])) {
624
+ errors.push(
625
+ `secretSetAttachments[${attachmentIndex}].mappings[${mappingIndex}] contains unsupported fields`,
626
+ );
627
+ }
439
628
  if (!isSecretKey(mappingCandidate.sourceKey)) {
440
629
  errors.push(
441
630
  `secretSetAttachments[${attachmentIndex}].mappings[${mappingIndex}].sourceKey must be canonical`,
@@ -475,21 +664,6 @@ export const validateServiceSecretConfiguration = (
475
664
  return errors;
476
665
  };
477
666
 
478
- export const validateServiceSecretMode = (serviceDataArg: unknown): string[] => {
479
- if (!isRecord(serviceDataArg)) return ['service data must be an object'];
480
- const hasLegacyPrimary = typeof serviceDataArg.secretBundleId === 'string'
481
- && serviceDataArg.secretBundleId.length > 0;
482
- const hasLegacyAdditional = Array.isArray(serviceDataArg.additionalSecretBundleIds)
483
- && serviceDataArg.additionalSecretBundleIds.length > 0;
484
- if (serviceDataArg.secretConfiguration !== undefined
485
- && (hasLegacyPrimary || hasLegacyAdditional)) {
486
- return ['legacy SecretBundle references cannot be mixed with secretConfiguration'];
487
- }
488
- return serviceDataArg.secretConfiguration === undefined
489
- ? []
490
- : validateServiceSecretConfiguration(serviceDataArg.secretConfiguration);
491
- };
492
-
493
667
  export const validateSecretSetAttachmentOwnership = (
494
668
  configurationArg: IServiceSecretConfiguration,
495
669
  serviceOrganizationIdArg: string,
@@ -521,6 +695,10 @@ export const createResolvedSecretManifestDigestInput = (
521
695
  organizationId: manifestArg.organizationId,
522
696
  serviceId: manifestArg.serviceId,
523
697
  clusterId: manifestArg.clusterId,
698
+ imageRolloutId: manifestArg.imageRolloutId,
699
+ imageRolloutGeneration: manifestArg.imageRolloutGeneration,
700
+ requestedImageDigest: manifestArg.requestedImageDigest,
701
+ invocationDigest: manifestArg.invocationDigest,
524
702
  secretRolloutId: manifestArg.secretRolloutId,
525
703
  secretRolloutGeneration: manifestArg.secretRolloutGeneration,
526
704
  entries: manifestArg.entries.map((entryArg) => ({
@@ -537,10 +715,13 @@ export const createResolvedSecretManifestDigestInput = (
537
715
  kind: 'secret-set',
538
716
  secretSetId: entryArg.owner.secretSetId,
539
717
  },
540
- delivery: entryArg.delivery.type === 'environment'
718
+ delivery: entryArg.delivery.type === 'launcher-environment'
541
719
  ? {
542
- type: 'environment',
720
+ type: 'launcher-environment',
543
721
  variableName: entryArg.delivery.variableName,
722
+ uid: entryArg.delivery.uid,
723
+ gid: entryArg.delivery.gid,
724
+ mode: entryArg.delivery.mode,
544
725
  }
545
726
  : {
546
727
  type: 'file',
@@ -576,12 +757,31 @@ export const validateResolvedSecretManifest = (
576
757
  ): string[] => {
577
758
  if (!isRecord(manifestArg)) return ['secret manifest must be an object'];
578
759
  const errors: string[] = [];
579
- if (manifestArg.schemaVersion !== 1) errors.push('secret manifest schemaVersion must be 1');
760
+ if (!hasExactKeys(manifestArg, [
761
+ 'schemaVersion',
762
+ 'id',
763
+ 'digest',
764
+ 'organizationId',
765
+ 'serviceId',
766
+ 'clusterId',
767
+ 'imageRolloutId',
768
+ 'imageRolloutGeneration',
769
+ 'requestedImageDigest',
770
+ 'invocationDigest',
771
+ 'secretRolloutId',
772
+ 'secretRolloutGeneration',
773
+ 'entries',
774
+ 'createdAt',
775
+ ])) {
776
+ errors.push('secret manifest must use its exact schema');
777
+ }
778
+ if (manifestArg.schemaVersion !== 2) errors.push('secret manifest schemaVersion must be 2');
580
779
  for (const field of [
581
780
  'id',
582
781
  'organizationId',
583
782
  'serviceId',
584
783
  'clusterId',
784
+ 'imageRolloutId',
585
785
  'secretRolloutId',
586
786
  ] as const) {
587
787
  if (!isSecretIdentifier(manifestArg[field])) {
@@ -591,6 +791,15 @@ export const validateResolvedSecretManifest = (
591
791
  if (typeof manifestArg.digest !== 'string' || !secretDigestRegex.test(manifestArg.digest)) {
592
792
  errors.push('secret manifest digest must be a canonical sha256 digest');
593
793
  }
794
+ for (const field of ['requestedImageDigest', 'invocationDigest'] as const) {
795
+ if (typeof manifestArg[field] !== 'string' || !secretDigestRegex.test(manifestArg[field])) {
796
+ errors.push(`secret manifest ${field} must be a canonical sha256 digest`);
797
+ }
798
+ }
799
+ if (!Number.isSafeInteger(manifestArg.imageRolloutGeneration)
800
+ || (manifestArg.imageRolloutGeneration as number) < 1) {
801
+ errors.push('secret manifest image rollout generation must be a positive integer');
802
+ }
594
803
  if (!Number.isSafeInteger(manifestArg.secretRolloutGeneration)
595
804
  || (manifestArg.secretRolloutGeneration as number) < 1) {
596
805
  errors.push('secret manifest rollout generation must be a positive integer');
@@ -613,6 +822,16 @@ export const validateResolvedSecretManifest = (
613
822
  errors.push(`secret manifest entries[${index}] must be an object`);
614
823
  continue;
615
824
  }
825
+ if (!hasExactKeys(candidate, [
826
+ 'secretId',
827
+ 'secretVersionId',
828
+ 'key',
829
+ 'environment',
830
+ 'owner',
831
+ 'delivery',
832
+ ])) {
833
+ errors.push(`secret manifest entries[${index}] must use its exact schema`);
834
+ }
616
835
  if (!isSecretIdentifier(candidate.secretId)) {
617
836
  errors.push(`secret manifest entries[${index}].secretId must be canonical`);
618
837
  } else {
@@ -636,6 +855,12 @@ export const validateResolvedSecretManifest = (
636
855
  candidate.owner.kind !== 'service'
637
856
  && candidate.owner.kind !== 'secret-set'
638
857
  )
858
+ || !hasExactKeys(
859
+ candidate.owner,
860
+ candidate.owner.kind === 'service'
861
+ ? ['kind', 'serviceId']
862
+ : ['kind', 'secretSetId'],
863
+ )
639
864
  || (
640
865
  candidate.owner.kind === 'service'
641
866
  && !isSecretIdentifier(candidate.owner.serviceId)
@@ -659,7 +884,7 @@ export const validateResolvedSecretManifest = (
659
884
  ));
660
885
  if (isRecord(candidate.delivery)) {
661
886
  if (
662
- candidate.delivery.type === 'environment'
887
+ candidate.delivery.type === 'launcher-environment'
663
888
  && isSecretKey(candidate.delivery.variableName)
664
889
  ) {
665
890
  environmentDeliveryTargets.push(candidate.delivery.variableName);
@@ -703,10 +928,28 @@ const validateResolvedSecretManifestReference = (
703
928
  pathArg: string,
704
929
  ): string[] => {
705
930
  if (!isRecord(referenceArg)
931
+ || !hasExactKeys(referenceArg, [
932
+ 'manifestId',
933
+ 'manifestDigest',
934
+ 'clusterId',
935
+ 'imageRolloutId',
936
+ 'imageRolloutGeneration',
937
+ 'requestedImageDigest',
938
+ 'invocationDigest',
939
+ 'secretRolloutId',
940
+ 'secretRolloutGeneration',
941
+ ])
706
942
  || !isSecretIdentifier(referenceArg.manifestId)
707
943
  || typeof referenceArg.manifestDigest !== 'string'
708
944
  || !secretDigestRegex.test(referenceArg.manifestDigest)
709
945
  || referenceArg.clusterId !== expectedClusterIdArg
946
+ || !isSecretIdentifier(referenceArg.imageRolloutId)
947
+ || !Number.isSafeInteger(referenceArg.imageRolloutGeneration)
948
+ || (referenceArg.imageRolloutGeneration as number) < 1
949
+ || typeof referenceArg.requestedImageDigest !== 'string'
950
+ || !secretDigestRegex.test(referenceArg.requestedImageDigest)
951
+ || typeof referenceArg.invocationDigest !== 'string'
952
+ || !secretDigestRegex.test(referenceArg.invocationDigest)
710
953
  || !isSecretIdentifier(referenceArg.secretRolloutId)
711
954
  || !Number.isSafeInteger(referenceArg.secretRolloutGeneration)
712
955
  || (referenceArg.secretRolloutGeneration as number) < 1) {
@@ -721,7 +964,15 @@ export const validateServiceSecretDeploymentPlan = (
721
964
  ): string[] => {
722
965
  if (!isRecord(planArg)) return ['secret deployment plan must be an object'];
723
966
  const errors: string[] = [];
724
- if (planArg.schemaVersion !== 1) errors.push('secret deployment plan schemaVersion must be 1');
967
+ if (!hasExactKeys(planArg, [
968
+ 'schemaVersion',
969
+ 'revision',
970
+ 'clusterStates',
971
+ 'updatedAt',
972
+ ])) {
973
+ errors.push('secret deployment plan must use its exact schema');
974
+ }
975
+ if (planArg.schemaVersion !== 2) errors.push('secret deployment plan schemaVersion must be 2');
725
976
  if (!Number.isSafeInteger(planArg.revision) || (planArg.revision as number) < 1) {
726
977
  errors.push('secret deployment plan revision must be a positive integer');
727
978
  }
@@ -767,6 +1018,16 @@ export const validateServiceSecretDeploymentPlan = (
767
1018
  errors.push(`${path}.clusterId must be canonical`);
768
1019
  continue;
769
1020
  }
1021
+ if (!hasOnlyKeys(candidate, [
1022
+ 'clusterId',
1023
+ 'desiredManifest',
1024
+ 'appliedManifest',
1025
+ 'previousAcceptedManifest',
1026
+ 'status',
1027
+ 'updatedAt',
1028
+ ])) {
1029
+ errors.push(`${path} contains unsupported fields`);
1030
+ }
770
1031
  clusterIds.push(candidate.clusterId);
771
1032
  validateAndTrackManifestReference(
772
1033
  candidate.desiredManifest,
@@ -782,6 +1043,7 @@ export const validateServiceSecretDeploymentPlan = (
782
1043
  }
783
1044
  if (candidate.previousAcceptedManifest !== undefined) {
784
1045
  if (!isRecord(candidate.previousAcceptedManifest)
1046
+ || !hasExactKeys(candidate.previousAcceptedManifest, ['manifest', 'acceptedUntil'])
785
1047
  || !Number.isSafeInteger(candidate.previousAcceptedManifest.acceptedUntil)
786
1048
  || (candidate.previousAcceptedManifest.acceptedUntil as number) < 0) {
787
1049
  errors.push(`${path}.previousAcceptedManifest acceptedUntil must be non-negative`);
@@ -833,3 +1095,463 @@ export const validateServiceSecretDeploymentPlan = (
833
1095
  }
834
1096
  return errors;
835
1097
  };
1098
+
1099
+ export const dockerSecretResourceNameDomain = 'serve.zone/docker-secret-resource/v1' as const;
1100
+ export const workloadInitEnvironmentMapPath = '/run/serve.zone/workloadinit-map-v1.json' as const;
1101
+ export const workloadRuntimeAssetRoot = '/opt/serve.zone/runtime-assets' as const;
1102
+ export const workloadInitExecutablePath =
1103
+ '/opt/serve.zone/runtime-assets/workloadinit/v1/workloadinit' as const;
1104
+ export const workloadInitEnvironmentMapMode = 0o444 as const;
1105
+ export const workloadRuntimeAssetsReadOnly = true as const;
1106
+
1107
+ const encodeLowercaseBase32 = (bytesArg: Uint8Array): string => {
1108
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz234567';
1109
+ let output = '';
1110
+ let accumulator = 0;
1111
+ let bitCount = 0;
1112
+ for (const byte of bytesArg) {
1113
+ accumulator = (accumulator << 8) | byte;
1114
+ bitCount += 8;
1115
+ while (bitCount >= 5) {
1116
+ bitCount -= 5;
1117
+ output += alphabet[(accumulator >>> bitCount) & 31];
1118
+ }
1119
+ accumulator &= (1 << bitCount) - 1;
1120
+ }
1121
+ if (bitCount > 0) output += alphabet[(accumulator << (5 - bitCount)) & 31];
1122
+ return output;
1123
+ };
1124
+
1125
+ /** Stable resource name; no logical key or value material appears in Docker. */
1126
+ export const createDockerSecretResourceName = async (
1127
+ secretVersionIdArg: string,
1128
+ ): Promise<`szsv-${string}`> => {
1129
+ if (!isSecretIdentifier(secretVersionIdArg)) {
1130
+ throw new Error('secretVersionId must be a canonical identifier');
1131
+ }
1132
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest(
1133
+ 'SHA-256',
1134
+ new TextEncoder().encode(`${dockerSecretResourceNameDomain}\0${secretVersionIdArg}`),
1135
+ ));
1136
+ const encoded = encodeLowercaseBase32(digest);
1137
+ if (encoded.length !== 52) throw new Error('unexpected SHA-256 base32 length');
1138
+ return `szsv-${encoded}`;
1139
+ };
1140
+
1141
+ export const createLauncherSecretSourcePath = (
1142
+ resourceArg: string,
1143
+ ): `/run/serve.zone/secrets/${string}` => {
1144
+ if (!/^szsv-[a-z2-7]{52}$/.test(resourceArg)) {
1145
+ throw new Error('resource must be a canonical serve.zone secret resource name');
1146
+ }
1147
+ return `/run/serve.zone/secrets/${resourceArg}`;
1148
+ };
1149
+
1150
+ export interface IWorkloadInitEnvironmentMapEntryV1 {
1151
+ variableName: string;
1152
+ resource: string;
1153
+ source: string;
1154
+ }
1155
+
1156
+ export interface IWorkloadInitEnvironmentMapV1 {
1157
+ schemaVersion: 1;
1158
+ manifestId: string;
1159
+ manifestDigest: TSha256Digest;
1160
+ entries: IWorkloadInitEnvironmentMapEntryV1[];
1161
+ }
1162
+
1163
+ /**
1164
+ * The nonsecret map is generated only from launcher deliveries. Direct file
1165
+ * deliveries mount their version resource at the declared target without the
1166
+ * launcher. Runtime assets are mounted read-only; no shell, symlink, or
1167
+ * aggregate material file participates in delivery.
1168
+ */
1169
+ export const createWorkloadInitEnvironmentMap = async (
1170
+ manifestArg: IResolvedSecretManifest,
1171
+ ): Promise<IWorkloadInitEnvironmentMapV1> => {
1172
+ if (validateResolvedSecretManifest(manifestArg).length > 0
1173
+ || !await verifyResolvedSecretManifestDigest(manifestArg)) {
1174
+ throw new Error('workload init map requires a valid digest-verified manifest');
1175
+ }
1176
+ const entries = await Promise.all(manifestArg.entries
1177
+ .filter((entryArg) => entryArg.delivery.type === 'launcher-environment')
1178
+ .map(async (entryArg) => {
1179
+ const resource = await createDockerSecretResourceName(entryArg.secretVersionId);
1180
+ return {
1181
+ variableName: (entryArg.delivery as ISecretLauncherEnvironmentDelivery).variableName,
1182
+ resource,
1183
+ source: createLauncherSecretSourcePath(resource),
1184
+ };
1185
+ }));
1186
+ entries.sort((leftArg, rightArg) => (
1187
+ leftArg.variableName < rightArg.variableName
1188
+ ? -1
1189
+ : leftArg.variableName > rightArg.variableName ? 1 : 0
1190
+ ));
1191
+ if (new Set(entries.map((entryArg) => entryArg.variableName)).size !== entries.length
1192
+ || new Set(entries.map((entryArg) => entryArg.resource)).size !== entries.length
1193
+ || new Set(entries.map((entryArg) => entryArg.source)).size !== entries.length) {
1194
+ throw new Error('workload init map entries must have unique variable, resource, and source fields');
1195
+ }
1196
+ return {
1197
+ schemaVersion: 1,
1198
+ manifestId: manifestArg.id,
1199
+ manifestDigest: manifestArg.digest,
1200
+ entries,
1201
+ };
1202
+ };
1203
+
1204
+ export const verifyWorkloadInitEnvironmentMap = async (
1205
+ mapArg: unknown,
1206
+ manifestArg: IResolvedSecretManifest,
1207
+ ): Promise<boolean> => {
1208
+ if (!isRecord(mapArg)
1209
+ || !hasExactKeys(mapArg, ['schemaVersion', 'manifestId', 'manifestDigest', 'entries'])) {
1210
+ return false;
1211
+ }
1212
+ try {
1213
+ const expected = await createWorkloadInitEnvironmentMap(manifestArg);
1214
+ return JSON.stringify(mapArg) === JSON.stringify(expected);
1215
+ } catch {
1216
+ return false;
1217
+ }
1218
+ };
1219
+
1220
+ export interface IWorkloadInitWrappedInvocation {
1221
+ command: [typeof workloadInitExecutablePath];
1222
+ args: string[];
1223
+ }
1224
+
1225
+ export const createWorkloadInitWrappedInvocation = (
1226
+ originalArgvArg: string[],
1227
+ ): IWorkloadInitWrappedInvocation => {
1228
+ if (!Array.isArray(originalArgvArg)
1229
+ || originalArgvArg.length === 0
1230
+ || originalArgvArg.some((argumentArg) => typeof argumentArg !== 'string'
1231
+ || argumentArg.length === 0
1232
+ || argumentArg.includes('\0'))) {
1233
+ throw new Error('original argv must be a non-empty canonical argument vector');
1234
+ }
1235
+ return {
1236
+ command: [workloadInitExecutablePath],
1237
+ args: ['run', '--map', workloadInitEnvironmentMapPath, '--', ...originalArgvArg],
1238
+ };
1239
+ };
1240
+
1241
+ /**
1242
+ * A Docker secret resource and its nonsecret map remain while any desired,
1243
+ * applied, or previous-accepted retained manifest references them. Deletion is
1244
+ * allowed only after no retained reference exists and Docker confirms the
1245
+ * corresponding rollout or service removal.
1246
+ */
1247
+ export interface ISecretRuntimeResourceRetention {
1248
+ resource: string;
1249
+ retainedManifestIds: string[];
1250
+ dockerRemovalConfirmed: boolean;
1251
+ }
1252
+
1253
+ export type TSecretRecipientLifecycleState = 'active' | 'retiring' | 'revoked';
1254
+
1255
+ interface ISecretRecipientMetadataBase {
1256
+ schemaVersion: 1;
1257
+ recipientKeyId: string;
1258
+ /** Canonical unpadded base64url raw 32-byte X25519 public key. */
1259
+ publicKey: string;
1260
+ generation: number;
1261
+ activatedAt: number;
1262
+ }
1263
+
1264
+ export interface IActiveSecretRecipientMetadata extends ISecretRecipientMetadataBase {
1265
+ lifecycleState: 'active';
1266
+ acceptUntil?: never;
1267
+ revokedAt?: never;
1268
+ }
1269
+
1270
+ export interface IRetiringSecretRecipientMetadata extends ISecretRecipientMetadataBase {
1271
+ lifecycleState: 'retiring';
1272
+ acceptUntil: number;
1273
+ revokedAt?: never;
1274
+ }
1275
+
1276
+ export interface IRevokedSecretRecipientMetadata extends ISecretRecipientMetadataBase {
1277
+ lifecycleState: 'revoked';
1278
+ acceptUntil?: never;
1279
+ revokedAt: number;
1280
+ }
1281
+
1282
+ export type TSecretRecipientMetadata =
1283
+ | IActiveSecretRecipientMetadata
1284
+ | IRetiringSecretRecipientMetadata
1285
+ | IRevokedSecretRecipientMetadata;
1286
+
1287
+ export type TSecretIngressRecipient = TSecretRecipientMetadata;
1288
+
1289
+ export const secretRecipientLimits = Object.freeze({
1290
+ maximumRecipients: 64,
1291
+ });
1292
+
1293
+ const rawX25519PublicKeyRegex = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
1294
+
1295
+ export const validateSecretRecipientMetadata = (
1296
+ recipientArg: unknown,
1297
+ ): string[] => {
1298
+ if (!isRecord(recipientArg)) return ['secret recipient must be an object'];
1299
+ const lifecycleState = recipientArg.lifecycleState;
1300
+ const requiredKeys = lifecycleState === 'active'
1301
+ ? ['schemaVersion', 'recipientKeyId', 'publicKey', 'generation', 'activatedAt', 'lifecycleState']
1302
+ : lifecycleState === 'retiring'
1303
+ ? ['schemaVersion', 'recipientKeyId', 'publicKey', 'generation', 'activatedAt', 'lifecycleState', 'acceptUntil']
1304
+ : lifecycleState === 'revoked'
1305
+ ? ['schemaVersion', 'recipientKeyId', 'publicKey', 'generation', 'activatedAt', 'lifecycleState', 'revokedAt']
1306
+ : [];
1307
+ if (requiredKeys.length === 0 || !hasExactKeys(recipientArg, requiredKeys)) {
1308
+ return ['secret recipient must use its exact lifecycle schema'];
1309
+ }
1310
+ const errors: string[] = [];
1311
+ if (recipientArg.schemaVersion !== 1) errors.push('secret recipient schemaVersion must be 1');
1312
+ if (!isSecretIdentifier(recipientArg.recipientKeyId)) {
1313
+ errors.push('secret recipient key id must be canonical');
1314
+ }
1315
+ if (typeof recipientArg.publicKey !== 'string'
1316
+ || !rawX25519PublicKeyRegex.test(recipientArg.publicKey)) {
1317
+ errors.push('secret recipient public key must be canonical base64url raw X25519 material');
1318
+ }
1319
+ for (const field of ['generation', 'activatedAt'] as const) {
1320
+ if (!Number.isSafeInteger(recipientArg[field]) || (recipientArg[field] as number) < 1) {
1321
+ errors.push(`secret recipient ${field} must be a positive integer`);
1322
+ }
1323
+ }
1324
+ if (lifecycleState === 'retiring'
1325
+ && (!Number.isSafeInteger(recipientArg.acceptUntil)
1326
+ || (recipientArg.acceptUntil as number) <= (recipientArg.activatedAt as number))) {
1327
+ errors.push('retiring secret recipient acceptUntil must follow activation');
1328
+ }
1329
+ if (lifecycleState === 'revoked'
1330
+ && (!Number.isSafeInteger(recipientArg.revokedAt)
1331
+ || (recipientArg.revokedAt as number) < (recipientArg.activatedAt as number))) {
1332
+ errors.push('revoked secret recipient revokedAt must not precede activation');
1333
+ }
1334
+ return errors;
1335
+ };
1336
+
1337
+ export const validateSecretRecipientSet = (
1338
+ recipientsArg: unknown,
1339
+ ): string[] => {
1340
+ if (!Array.isArray(recipientsArg)
1341
+ || recipientsArg.length === 0
1342
+ || recipientsArg.length > secretRecipientLimits.maximumRecipients) {
1343
+ return [
1344
+ `secret recipient set must contain between 1 and ${secretRecipientLimits.maximumRecipients} entries`,
1345
+ ];
1346
+ }
1347
+ const errors = recipientsArg.flatMap((recipientArg, indexArg) => (
1348
+ validateSecretRecipientMetadata(recipientArg)
1349
+ .map((errorArg) => `recipients[${indexArg}].${errorArg}`)
1350
+ ));
1351
+ const recipients = recipientsArg.filter(isRecord);
1352
+ if (recipients.filter((recipientArg) => recipientArg.lifecycleState === 'active').length !== 1) {
1353
+ errors.push('secret recipient set must contain exactly one active recipient');
1354
+ }
1355
+ const keyIds = recipients.map((recipientArg) => recipientArg.recipientKeyId);
1356
+ const generations = recipients.map((recipientArg) => recipientArg.generation);
1357
+ if (new Set(keyIds).size !== keyIds.length) errors.push('secret recipient key ids must be unique');
1358
+ if (new Set(generations).size !== generations.length) {
1359
+ errors.push('secret recipient generations must be unique');
1360
+ }
1361
+ const activeRecipient = recipients.find((recipientArg) => recipientArg.lifecycleState === 'active');
1362
+ const numericGenerations = generations.filter((generationArg): generationArg is number => (
1363
+ Number.isSafeInteger(generationArg)
1364
+ ));
1365
+ if (activeRecipient
1366
+ && Number.isSafeInteger(activeRecipient.generation)
1367
+ && numericGenerations.length > 0
1368
+ && activeRecipient.generation !== Math.max(...numericGenerations)) {
1369
+ errors.push('the active secret recipient must have the greatest generation');
1370
+ }
1371
+ return errors;
1372
+ };
1373
+
1374
+ /** New envelopes target only active recipients. */
1375
+ export const canIssueNewSecretEnvelope = (
1376
+ recipientArg: TSecretRecipientMetadata,
1377
+ ): boolean => recipientArg.lifecycleState === 'active';
1378
+
1379
+ export const validateSecretValueInputForRecipient = async (
1380
+ valueInputArg: unknown,
1381
+ recipientArg: TSecretRecipientMetadata,
1382
+ expectedContextArg: Uint8Array,
1383
+ ): Promise<string[]> => {
1384
+ const errors = validateSecretValueInput(valueInputArg);
1385
+ if (errors.length > 0 || !isRecord(valueInputArg) || valueInputArg.mode !== 'sealed') {
1386
+ return errors;
1387
+ }
1388
+ if (!canIssueNewSecretEnvelope(recipientArg)) {
1389
+ errors.push('new sealed inputs require the active ingress recipient');
1390
+ }
1391
+ try {
1392
+ const envelope = plugins.smartcrypto.parseX25519Envelope(valueInputArg.envelope);
1393
+ if (envelope.recipientKeyId !== recipientArg.recipientKeyId) {
1394
+ errors.push('sealed input recipient does not match the active ingress recipient');
1395
+ }
1396
+ if (!await verifySecretEnvelopeContext(envelope, expectedContextArg)) {
1397
+ errors.push('sealed input context does not match the request');
1398
+ }
1399
+ } catch {
1400
+ // validateSecretValueInput already reports malformed envelopes.
1401
+ }
1402
+ return errors;
1403
+ };
1404
+
1405
+ /** Retiring recipients open only already-issued envelopes before their deadline. */
1406
+ export const canOpenIssuedSecretEnvelope = (
1407
+ recipientArg: TSecretRecipientMetadata,
1408
+ issuedAtArg: number,
1409
+ nowArg: number,
1410
+ ): boolean => Number.isSafeInteger(issuedAtArg)
1411
+ && Number.isSafeInteger(nowArg)
1412
+ && issuedAtArg >= recipientArg.activatedAt
1413
+ && issuedAtArg <= nowArg
1414
+ && (recipientArg.lifecycleState === 'active'
1415
+ || (recipientArg.lifecycleState === 'retiring'
1416
+ && issuedAtArg < recipientArg.acceptUntil
1417
+ && nowArg < recipientArg.acceptUntil));
1418
+
1419
+ const canonicalSecretOwner = (ownerArg: TSecretOwner): Record<string, string> => {
1420
+ switch (ownerArg.kind) {
1421
+ case 'service': return { kind: ownerArg.kind, serviceId: ownerArg.serviceId };
1422
+ case 'secret-set': return { kind: ownerArg.kind, secretSetId: ownerArg.secretSetId };
1423
+ case 'platform-provider': return {
1424
+ kind: ownerArg.kind,
1425
+ providerConfigId: ownerArg.providerConfigId,
1426
+ };
1427
+ case 'system': return { kind: ownerArg.kind, systemId: ownerArg.systemId };
1428
+ }
1429
+ };
1430
+
1431
+ const canonicalSecretDelivery = (deliveryArg: TSecretDelivery): Record<string, unknown> => (
1432
+ deliveryArg.type === 'launcher-environment'
1433
+ ? {
1434
+ type: deliveryArg.type,
1435
+ variableName: deliveryArg.variableName,
1436
+ uid: deliveryArg.uid,
1437
+ gid: deliveryArg.gid,
1438
+ mode: deliveryArg.mode,
1439
+ }
1440
+ : {
1441
+ type: deliveryArg.type,
1442
+ targetPath: deliveryArg.targetPath,
1443
+ uid: deliveryArg.uid,
1444
+ gid: deliveryArg.gid,
1445
+ mode: deliveryArg.mode,
1446
+ }
1447
+ );
1448
+
1449
+ const contextBytes = (valueArg: Record<string, unknown>): Uint8Array => (
1450
+ new TextEncoder().encode(JSON.stringify(valueArg))
1451
+ );
1452
+
1453
+ export const createSecretCreateEnvelopeContext = (contextArg: {
1454
+ mutationId: string;
1455
+ target: TSecretMutationTarget;
1456
+ key: string;
1457
+ environment: TSecretEnvironment;
1458
+ delivery: TSecretDelivery;
1459
+ expectedTargetSecretsRevision: number;
1460
+ }): Uint8Array => contextBytes({
1461
+ schemaVersion: 1,
1462
+ purpose: 'serve.zone/secret-create',
1463
+ mutationId: contextArg.mutationId,
1464
+ target: canonicalSecretOwner(contextArg.target),
1465
+ key: contextArg.key,
1466
+ environment: contextArg.environment,
1467
+ delivery: canonicalSecretDelivery(contextArg.delivery),
1468
+ expectedTargetSecretsRevision: contextArg.expectedTargetSecretsRevision,
1469
+ });
1470
+
1471
+ export const createSecretRotateEnvelopeContext = (contextArg: {
1472
+ mutationId: string;
1473
+ secretId: string;
1474
+ expectedSecretRevision: number;
1475
+ expectedActiveVersionId: string;
1476
+ expectedTargetSecretsRevision: number;
1477
+ }): Uint8Array => contextBytes({
1478
+ schemaVersion: 1,
1479
+ purpose: 'serve.zone/secret-rotate',
1480
+ mutationId: contextArg.mutationId,
1481
+ secretId: contextArg.secretId,
1482
+ expectedSecretRevision: contextArg.expectedSecretRevision,
1483
+ expectedActiveVersionId: contextArg.expectedActiveVersionId,
1484
+ expectedTargetSecretsRevision: contextArg.expectedTargetSecretsRevision,
1485
+ });
1486
+
1487
+ export const createAppStoreInstallSecretEnvelopeContext = (contextArg: {
1488
+ mutationId: string;
1489
+ appId: string;
1490
+ appVersion: string;
1491
+ serviceId: string;
1492
+ key: string;
1493
+ delivery: TSecretDelivery;
1494
+ generation?: { encoding: TSecretGeneratedEncoding; bytes: number };
1495
+ }): Uint8Array => contextBytes({
1496
+ schemaVersion: 1,
1497
+ purpose: 'serve.zone/app-install-secret',
1498
+ mutationId: contextArg.mutationId,
1499
+ appId: contextArg.appId,
1500
+ appVersion: contextArg.appVersion,
1501
+ serviceId: contextArg.serviceId,
1502
+ key: contextArg.key,
1503
+ delivery: canonicalSecretDelivery(contextArg.delivery),
1504
+ generation: contextArg.generation
1505
+ ? { encoding: contextArg.generation.encoding, bytes: contextArg.generation.bytes }
1506
+ : null,
1507
+ });
1508
+
1509
+ export const createResolvedSecretMaterialEnvelopeContext = (contextArg: {
1510
+ recipientKeyId: string;
1511
+ organizationId: string;
1512
+ clusterId: string;
1513
+ serviceId: string;
1514
+ imageRolloutId: string;
1515
+ imageRolloutGeneration: number;
1516
+ requestedImageDigest: TSha256Digest;
1517
+ secretRolloutId: string;
1518
+ secretRolloutGeneration: number;
1519
+ manifestId: string;
1520
+ manifestDigest: TSha256Digest;
1521
+ secretId: string;
1522
+ secretVersionId: string;
1523
+ }): Uint8Array => contextBytes({
1524
+ schemaVersion: 1,
1525
+ purpose: 'serve.zone/resolved-secret-material',
1526
+ recipientKeyId: contextArg.recipientKeyId,
1527
+ organizationId: contextArg.organizationId,
1528
+ clusterId: contextArg.clusterId,
1529
+ serviceId: contextArg.serviceId,
1530
+ imageRolloutId: contextArg.imageRolloutId,
1531
+ imageRolloutGeneration: contextArg.imageRolloutGeneration,
1532
+ requestedImageDigest: contextArg.requestedImageDigest,
1533
+ secretRolloutId: contextArg.secretRolloutId,
1534
+ secretRolloutGeneration: contextArg.secretRolloutGeneration,
1535
+ manifestId: contextArg.manifestId,
1536
+ manifestDigest: contextArg.manifestDigest,
1537
+ secretId: contextArg.secretId,
1538
+ secretVersionId: contextArg.secretVersionId,
1539
+ });
1540
+
1541
+ export const createSecretRecipientEnrollmentChallengeContext = (contextArg: {
1542
+ enrollmentId: string;
1543
+ clusterId: string;
1544
+ recipientKeyId: string;
1545
+ publicKey: string;
1546
+ generation: number;
1547
+ expiresAt: number;
1548
+ }): Uint8Array => contextBytes({
1549
+ schemaVersion: 1,
1550
+ purpose: 'serve.zone/secret-recipient-enrollment-challenge',
1551
+ enrollmentId: contextArg.enrollmentId,
1552
+ clusterId: contextArg.clusterId,
1553
+ recipientKeyId: contextArg.recipientKeyId,
1554
+ publicKey: contextArg.publicKey,
1555
+ generation: contextArg.generation,
1556
+ expiresAt: contextArg.expiresAt,
1557
+ });