@serve.zone/interfaces 19.8.0 → 20.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/changelog.md +28 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/data/immutableimage.d.ts +2 -0
  4. package/dist_ts/data/immutableimage.js +1 -1
  5. package/dist_ts/data/index.d.ts +1 -0
  6. package/dist_ts/data/index.js +2 -1
  7. package/dist_ts/data/isolatedrestore.js +7 -82
  8. package/dist_ts/data/secret.d.ts +205 -0
  9. package/dist_ts/data/secret.js +469 -0
  10. package/dist_ts/data/secretbundle.d.ts +4 -0
  11. package/dist_ts/data/secretgroup.d.ts +4 -0
  12. package/dist_ts/data/service.d.ts +18 -6
  13. package/dist_ts/data/service.js +1 -1
  14. package/dist_ts/platform/index.d.ts +3 -1
  15. package/dist_ts/platform/index.js +4 -2
  16. package/dist_ts/platform/storagemigration.d.ts +346 -0
  17. package/dist_ts/platform/storagemigration.golden.d.ts +49 -0
  18. package/dist_ts/platform/storagemigration.golden.js +207 -0
  19. package/dist_ts/platform/storagemigration.js +1554 -0
  20. package/dist_ts/private/canonicaljson.d.ts +14 -0
  21. package/dist_ts/private/canonicaljson.js +154 -0
  22. package/dist_ts/requests/index.d.ts +2 -1
  23. package/dist_ts/requests/index.js +3 -2
  24. package/dist_ts/requests/secret.d.ts +198 -0
  25. package/dist_ts/requests/secret.js +2 -0
  26. package/dist_ts/runtime.d.ts +41 -0
  27. package/dist_ts/runtime.js +33 -0
  28. package/package.json +9 -2
  29. package/readme.md +162 -2
  30. package/ts/00_commitinfo_data.ts +1 -1
  31. package/ts/data/immutableimage.ts +2 -0
  32. package/ts/data/index.ts +1 -0
  33. package/ts/data/isolatedrestore.ts +12 -93
  34. package/ts/data/secret.ts +835 -0
  35. package/ts/data/secretbundle.ts +4 -0
  36. package/ts/data/secretgroup.ts +4 -0
  37. package/ts/data/service.ts +26 -6
  38. package/ts/platform/index.ts +3 -0
  39. package/ts/platform/storagemigration.golden.ts +241 -0
  40. package/ts/platform/storagemigration.ts +3082 -0
  41. package/ts/private/canonicaljson.ts +221 -0
  42. package/ts/requests/index.ts +2 -0
  43. package/ts/requests/secret.ts +256 -0
  44. package/ts/runtime.ts +80 -0
@@ -0,0 +1,835 @@
1
+ import type { TSha256Digest } from './immutableimage.js';
2
+ import { normalizeServiceAbsolutePath } from './service.js';
3
+
4
+ export type TSecretEnvironment = string;
5
+
6
+ export type TSecretLifecycleState =
7
+ | 'active'
8
+ | 'retired'
9
+ | 'revoked'
10
+ | 'deleted';
11
+
12
+ export type TSecretVersionLifecycleState =
13
+ | 'active'
14
+ | 'retired'
15
+ | 'revoked'
16
+ | 'purged';
17
+
18
+ /** Retired versions remain usable only through an already-accepted manifest. */
19
+ export const isSecretVersionMaterialIssuable = (
20
+ lifecycleStateArg: TSecretVersionLifecycleState,
21
+ ): boolean => lifecycleStateArg === 'active' || lifecycleStateArg === 'retired';
22
+
23
+ export type TSecretManagementSource =
24
+ | 'operator'
25
+ | 'legacy'
26
+ | 'appstore'
27
+ | 'mail'
28
+ | 'oidc'
29
+ | 'webpush'
30
+ | 'platform'
31
+ | 'system';
32
+
33
+ export type TSecretManagementScope =
34
+ | 'operator'
35
+ | 'legacy'
36
+ | `appstore:${string}`
37
+ | `mail:${string}`
38
+ | `oidc:${string}`
39
+ | `webpush:${string}`
40
+ | `platform:${string}`
41
+ | `system:${string}`;
42
+
43
+ export interface IServiceSecretOwner {
44
+ kind: 'service';
45
+ serviceId: string;
46
+ }
47
+
48
+ export interface ISecretSetSecretOwner {
49
+ kind: 'secret-set';
50
+ secretSetId: string;
51
+ }
52
+
53
+ export type TSecretOwner = IServiceSecretOwner | ISecretSetSecretOwner;
54
+
55
+ export interface ISecretVersionReference {
56
+ secretId: string;
57
+ secretVersionId: string;
58
+ }
59
+
60
+ export type TSecretVersionRetentionReferenceKind =
61
+ | 'desired-manifest'
62
+ | 'applied-manifest'
63
+ | 'previous-accepted-manifest'
64
+ | 'rollback-manifest'
65
+ | 'backup'
66
+ | 'retention-window';
67
+
68
+ export interface ISecretVersionRetentionReference {
69
+ kind: TSecretVersionRetentionReferenceKind;
70
+ resourceId: string;
71
+ expiresAt?: number;
72
+ }
73
+
74
+ /** Browser-safe logical secret metadata. It never contains value material. */
75
+ export interface ISecretMetadata {
76
+ id: string;
77
+ organizationId: string;
78
+ owner: TSecretOwner;
79
+ key: string;
80
+ environment: TSecretEnvironment;
81
+ name: string;
82
+ description?: string;
83
+ tags: Array<{ key: string; value: string }>;
84
+ managementSource: TSecretManagementSource;
85
+ /** Exact reconciliation owner, for example operator or appstore:<appId>. */
86
+ managementScope: TSecretManagementScope;
87
+ defaultDelivery: TSecretDelivery;
88
+ lifecycleState: TSecretLifecycleState;
89
+ activeVersion?: ISecretVersionReference;
90
+ versionCount: number;
91
+ revision: number;
92
+ createdAt: number;
93
+ updatedAt: number;
94
+ lastRotatedAt?: number;
95
+ }
96
+
97
+ /** Browser-safe immutable-version metadata. It never contains an encrypted envelope. */
98
+ export interface ISecretVersionMetadata {
99
+ id: string;
100
+ secretId: string;
101
+ lifecycleState: TSecretVersionLifecycleState;
102
+ createdAt: number;
103
+ createdBy: string;
104
+ source: TSecretManagementSource;
105
+ retiredAt?: number;
106
+ revokedAt?: number;
107
+ purgedAt?: number;
108
+ }
109
+
110
+ export interface ISecretSetMetadata {
111
+ id: string;
112
+ organizationId: string;
113
+ name: string;
114
+ description?: string;
115
+ lifecycleState: 'active' | 'retired' | 'deleted';
116
+ revision: number;
117
+ /** Dedicated CAS revision for secrets owned by this set. */
118
+ secretsRevision: number;
119
+ currentConsumerRollout?: ISecretSetConsumerRolloutSummary;
120
+ createdAt: number;
121
+ updatedAt: number;
122
+ }
123
+
124
+ export type TSecretSetConsumerRolloutStatus =
125
+ | 'pending'
126
+ | 'published'
127
+ | 'applied'
128
+ | 'failed';
129
+
130
+ export interface ISecretSetConsumerRolloutSummary {
131
+ rolloutId: string;
132
+ secretId: string;
133
+ secretVersionId: string;
134
+ totalConsumers: number;
135
+ pendingConsumers: number;
136
+ failedConsumers: number;
137
+ updatedAt: number;
138
+ }
139
+
140
+ export interface ISecretSetConsumerRolloutStatus {
141
+ serviceId: string;
142
+ status: TSecretSetConsumerRolloutStatus;
143
+ desiredManifestIds: string[];
144
+ appliedManifestIds: string[];
145
+ errorCode?: string;
146
+ updatedAt: number;
147
+ }
148
+
149
+ export interface ISecretEnvironmentDelivery {
150
+ type: 'environment';
151
+ variableName: string;
152
+ }
153
+
154
+ export interface ISecretFileDelivery {
155
+ type: 'file';
156
+ targetPath: string;
157
+ uid: number;
158
+ gid: number;
159
+ mode: number;
160
+ }
161
+
162
+ export type TSecretDelivery = ISecretEnvironmentDelivery | ISecretFileDelivery;
163
+
164
+ export interface ISecretSetAttachmentMapping {
165
+ /** Source key in the attached SecretSet. */
166
+ sourceKey: string;
167
+ /** Final service key. Defaults to sourceKey. */
168
+ targetKey?: string;
169
+ /** Optional service-specific delivery override. */
170
+ delivery?: TSecretDelivery;
171
+ }
172
+
173
+ export interface ISecretSetAttachment {
174
+ id: string;
175
+ secretSetId: string;
176
+ environment: TSecretEnvironment;
177
+ mappings?: ISecretSetAttachmentMapping[];
178
+ }
179
+
180
+ /**
181
+ * Caller-managed value-free inputs to service secret resolution.
182
+ * Cloudly verifies organization ownership when resolving the referenced sets.
183
+ */
184
+ export interface IServiceSecretConfiguration {
185
+ schemaVersion: 1;
186
+ /** Dedicated CAS revision for SecretSet attachments. */
187
+ revision: number;
188
+ /** Dedicated CAS revision for service-owned secret membership. */
189
+ secretsRevision: number;
190
+ secretSetAttachments: ISecretSetAttachment[];
191
+ }
192
+
193
+ export interface IResolvedSecretManifestEntry {
194
+ secretId: string;
195
+ secretVersionId: string;
196
+ key: string;
197
+ environment: TSecretEnvironment;
198
+ owner: TSecretOwner;
199
+ delivery: TSecretDelivery;
200
+ }
201
+
202
+ /** Immutable, value-free runtime snapshot. */
203
+ export interface IResolvedSecretManifest {
204
+ schemaVersion: 1;
205
+ id: string;
206
+ digest: TSha256Digest;
207
+ organizationId: string;
208
+ serviceId: string;
209
+ clusterId: string;
210
+ secretRolloutId: string;
211
+ secretRolloutGeneration: number;
212
+ entries: IResolvedSecretManifestEntry[];
213
+ createdAt: number;
214
+ }
215
+
216
+ export interface IResolvedSecretManifestReference {
217
+ manifestId: string;
218
+ manifestDigest: TSha256Digest;
219
+ clusterId: string;
220
+ secretRolloutId: string;
221
+ secretRolloutGeneration: number;
222
+ }
223
+
224
+ export interface IAcceptedPreviousSecretManifest {
225
+ manifest: IResolvedSecretManifestReference;
226
+ /** Exclusive material-issuance deadline for tasks draining the previous rollout. */
227
+ acceptedUntil: number;
228
+ }
229
+
230
+ export type TClusterSecretDeploymentStatus =
231
+ | 'pending'
232
+ | 'applying'
233
+ | 'applied'
234
+ | 'drifted'
235
+ | 'failed';
236
+
237
+ export interface IClusterSecretDeploymentState {
238
+ clusterId: string;
239
+ desiredManifest: IResolvedSecretManifestReference;
240
+ appliedManifest?: IResolvedSecretManifestReference;
241
+ previousAcceptedManifest?: IAcceptedPreviousSecretManifest;
242
+ status: TClusterSecretDeploymentStatus;
243
+ updatedAt: number;
244
+ }
245
+
246
+ /** Server-managed per-cluster authority switched with an expected revision CAS. */
247
+ export interface IServiceSecretDeploymentPlan {
248
+ schemaVersion: 1;
249
+ revision: number;
250
+ clusterStates: IClusterSecretDeploymentState[];
251
+ updatedAt: number;
252
+ }
253
+
254
+ export const resolvedSecretManifestReferencesEqual = (
255
+ leftArg: IResolvedSecretManifestReference | undefined,
256
+ rightArg: IResolvedSecretManifestReference | undefined,
257
+ ): boolean => (
258
+ Boolean(leftArg)
259
+ && Boolean(rightArg)
260
+ && leftArg?.manifestId === rightArg?.manifestId
261
+ && leftArg?.manifestDigest === rightArg?.manifestDigest
262
+ && leftArg?.clusterId === rightArg?.clusterId
263
+ && leftArg?.secretRolloutId === rightArg?.secretRolloutId
264
+ && leftArg?.secretRolloutGeneration === rightArg?.secretRolloutGeneration
265
+ );
266
+
267
+ export const isResolvedSecretManifestReferenceAccepted = (
268
+ stateArg: IClusterSecretDeploymentState,
269
+ referenceArg: IResolvedSecretManifestReference,
270
+ nowArg: number,
271
+ ): boolean => (
272
+ resolvedSecretManifestReferencesEqual(stateArg.desiredManifest, referenceArg)
273
+ || resolvedSecretManifestReferencesEqual(stateArg.appliedManifest, referenceArg)
274
+ || (
275
+ Boolean(stateArg.previousAcceptedManifest)
276
+ && nowArg < stateArg.previousAcceptedManifest!.acceptedUntil
277
+ && resolvedSecretManifestReferencesEqual(
278
+ stateArg.previousAcceptedManifest!.manifest,
279
+ referenceArg,
280
+ )
281
+ )
282
+ );
283
+
284
+ const secretIdentifierRegex = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/;
285
+ const secretEnvironmentRegex = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
286
+ const secretKeyRegex = /^[A-Z_][A-Z0-9_]{0,252}$/;
287
+ const secretDigestRegex = /^sha256:[a-f0-9]{64}$/;
288
+ const secretManagementScopeRegex =
289
+ /^(appstore|mail|oidc|webpush|platform|system):[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
290
+
291
+ const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => (
292
+ Boolean(valueArg) && typeof valueArg === 'object' && !Array.isArray(valueArg)
293
+ );
294
+
295
+ const isSecretIdentifier = (valueArg: unknown): valueArg is string => (
296
+ typeof valueArg === 'string' && secretIdentifierRegex.test(valueArg)
297
+ );
298
+
299
+ const isSecretKey = (valueArg: unknown): valueArg is string => (
300
+ typeof valueArg === 'string' && secretKeyRegex.test(valueArg)
301
+ );
302
+
303
+ const isSecretEnvironment = (valueArg: unknown): valueArg is string => (
304
+ typeof valueArg === 'string' && secretEnvironmentRegex.test(valueArg)
305
+ );
306
+
307
+ export const validateSecretManagementScope = (
308
+ sourceArg: TSecretManagementSource,
309
+ scopeArg: unknown,
310
+ ): string[] => {
311
+ if (scopeArg === 'operator') {
312
+ return sourceArg === 'operator'
313
+ ? []
314
+ : ['operator management scope requires operator source'];
315
+ }
316
+ if (scopeArg === 'legacy') {
317
+ return sourceArg === 'legacy'
318
+ ? []
319
+ : ['legacy management scope requires legacy source'];
320
+ }
321
+ if (typeof scopeArg !== 'string' || !secretManagementScopeRegex.test(scopeArg)) {
322
+ return ['management scope must be canonical'];
323
+ }
324
+ return scopeArg.startsWith(`${sourceArg}:`)
325
+ ? []
326
+ : ['management scope prefix must match management source'];
327
+ };
328
+
329
+ export const validateSecretDelivery = (deliveryArg: unknown): string[] => {
330
+ 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'];
335
+ }
336
+ if (deliveryArg.type !== 'file') {
337
+ return ['delivery type must be environment or file'];
338
+ }
339
+ const errors: string[] = [];
340
+ if (typeof deliveryArg.targetPath !== 'string'
341
+ || 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') {
344
+ errors.push('file delivery targetPath must be a canonical file below /run/secrets');
345
+ }
346
+ for (const field of ['uid', 'gid'] as const) {
347
+ const value = deliveryArg[field];
348
+ if (!Number.isSafeInteger(value)
349
+ || (value as number) < 0
350
+ || (value as number) > 2_147_483_647) {
351
+ errors.push(`file delivery ${field} must be a bounded numeric identity`);
352
+ }
353
+ }
354
+ if (deliveryArg.mode !== 0o400 && deliveryArg.mode !== 0o440) {
355
+ errors.push('file delivery mode must be 0400 or 0440');
356
+ }
357
+ return errors;
358
+ };
359
+
360
+ /**
361
+ * One shared precedence rule for resolution and runtime verification.
362
+ * A manifest is authoritative once published; before publication an attachment
363
+ * override wins over the Secret default.
364
+ */
365
+ export const resolveSecretDelivery = (deliveryArg: {
366
+ secretDefault: TSecretDelivery;
367
+ attachmentOverride?: TSecretDelivery;
368
+ manifestDelivery?: TSecretDelivery;
369
+ }): TSecretDelivery => (
370
+ deliveryArg.manifestDelivery
371
+ ?? deliveryArg.attachmentOverride
372
+ ?? deliveryArg.secretDefault
373
+ );
374
+
375
+ export const validateServiceSecretConfiguration = (
376
+ configurationArg: unknown,
377
+ ): string[] => {
378
+ if (!isRecord(configurationArg)) {
379
+ return ['secretConfiguration must be an object'];
380
+ }
381
+ if (configurationArg.schemaVersion !== 1) {
382
+ return ['secretConfiguration schemaVersion must be 1'];
383
+ }
384
+ if (!Array.isArray(configurationArg.secretSetAttachments)) {
385
+ return ['secretSetAttachments must be an array'];
386
+ }
387
+ if (configurationArg.secretSetAttachments.length > 64) {
388
+ return ['secretSetAttachments must contain at most 64 entries'];
389
+ }
390
+ const errors: string[] = [];
391
+ if (!Number.isSafeInteger(configurationArg.revision)
392
+ || (configurationArg.revision as number) < 0) {
393
+ errors.push('secretConfiguration revision must be a non-negative integer');
394
+ }
395
+ if (!Number.isSafeInteger(configurationArg.secretsRevision)
396
+ || (configurationArg.secretsRevision as number) < 0) {
397
+ errors.push('secretConfiguration secretsRevision must be a non-negative integer');
398
+ }
399
+ const attachmentIds: string[] = [];
400
+ const setEnvironmentKeys: string[] = [];
401
+ for (const [attachmentIndex, candidate] of configurationArg.secretSetAttachments.entries()) {
402
+ if (!isRecord(candidate)) {
403
+ errors.push(`secretSetAttachments[${attachmentIndex}] must be an object`);
404
+ continue;
405
+ }
406
+ if (!isSecretIdentifier(candidate.id)) {
407
+ errors.push(`secretSetAttachments[${attachmentIndex}].id must be a canonical identifier`);
408
+ } else {
409
+ attachmentIds.push(candidate.id);
410
+ }
411
+ if (!isSecretIdentifier(candidate.secretSetId)) {
412
+ errors.push(
413
+ `secretSetAttachments[${attachmentIndex}].secretSetId must be a canonical identifier`,
414
+ );
415
+ }
416
+ if (!isSecretEnvironment(candidate.environment)) {
417
+ errors.push(
418
+ `secretSetAttachments[${attachmentIndex}].environment must be a canonical environment`,
419
+ );
420
+ }
421
+ if (isSecretIdentifier(candidate.secretSetId)
422
+ && isSecretEnvironment(candidate.environment)) {
423
+ setEnvironmentKeys.push(`${candidate.secretSetId}\0${candidate.environment}`);
424
+ }
425
+ if (candidate.mappings === undefined) continue;
426
+ if (!Array.isArray(candidate.mappings) || candidate.mappings.length > 128) {
427
+ errors.push(`secretSetAttachments[${attachmentIndex}].mappings must be a bounded array`);
428
+ continue;
429
+ }
430
+ const sourceKeys: string[] = [];
431
+ const targetKeys: string[] = [];
432
+ for (const [mappingIndex, mappingCandidate] of candidate.mappings.entries()) {
433
+ if (!isRecord(mappingCandidate)) {
434
+ errors.push(
435
+ `secretSetAttachments[${attachmentIndex}].mappings[${mappingIndex}] must be an object`,
436
+ );
437
+ continue;
438
+ }
439
+ if (!isSecretKey(mappingCandidate.sourceKey)) {
440
+ errors.push(
441
+ `secretSetAttachments[${attachmentIndex}].mappings[${mappingIndex}].sourceKey must be canonical`,
442
+ );
443
+ } else {
444
+ sourceKeys.push(mappingCandidate.sourceKey);
445
+ }
446
+ const finalKey = mappingCandidate.targetKey ?? mappingCandidate.sourceKey;
447
+ if (!isSecretKey(finalKey)) {
448
+ errors.push(
449
+ `secretSetAttachments[${attachmentIndex}].mappings[${mappingIndex}].targetKey must be canonical`,
450
+ );
451
+ } else {
452
+ targetKeys.push(finalKey);
453
+ }
454
+ if (mappingCandidate.delivery !== undefined) {
455
+ errors.push(...validateSecretDelivery(mappingCandidate.delivery).map(
456
+ (errorArg) => (
457
+ `secretSetAttachments[${attachmentIndex}].mappings[${mappingIndex}].${errorArg}`
458
+ ),
459
+ ));
460
+ }
461
+ }
462
+ if (new Set(sourceKeys).size !== sourceKeys.length) {
463
+ errors.push(`secretSetAttachments[${attachmentIndex}] has duplicate source mappings`);
464
+ }
465
+ if (new Set(targetKeys).size !== targetKeys.length) {
466
+ errors.push(`secretSetAttachments[${attachmentIndex}] has duplicate target mappings`);
467
+ }
468
+ }
469
+ if (new Set(attachmentIds).size !== attachmentIds.length) {
470
+ errors.push('secretSetAttachments ids must be unique');
471
+ }
472
+ if (new Set(setEnvironmentKeys).size !== setEnvironmentKeys.length) {
473
+ errors.push('a SecretSet may be attached only once per environment');
474
+ }
475
+ return errors;
476
+ };
477
+
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
+ export const validateSecretSetAttachmentOwnership = (
494
+ configurationArg: IServiceSecretConfiguration,
495
+ serviceOrganizationIdArg: string,
496
+ secretSetsArg: ISecretSetMetadata[],
497
+ ): string[] => {
498
+ const errors = validateServiceSecretConfiguration(configurationArg);
499
+ const secretSetsById = new Map(secretSetsArg.map((secretSetArg) => [
500
+ secretSetArg.id,
501
+ secretSetArg,
502
+ ]));
503
+ for (const attachment of configurationArg.secretSetAttachments || []) {
504
+ const secretSet = secretSetsById.get(attachment.secretSetId);
505
+ if (!secretSet) {
506
+ errors.push(`SecretSet ${attachment.secretSetId} does not exist`);
507
+ } else if (secretSet.organizationId !== serviceOrganizationIdArg) {
508
+ errors.push(`SecretSet ${attachment.secretSetId} belongs to another organization`);
509
+ } else if (secretSet.lifecycleState !== 'active') {
510
+ errors.push(`SecretSet ${attachment.secretSetId} is not active`);
511
+ }
512
+ }
513
+ return errors;
514
+ };
515
+
516
+ export const createResolvedSecretManifestDigestInput = (
517
+ manifestArg: IResolvedSecretManifest,
518
+ ): string => JSON.stringify({
519
+ schemaVersion: manifestArg.schemaVersion,
520
+ id: manifestArg.id,
521
+ organizationId: manifestArg.organizationId,
522
+ serviceId: manifestArg.serviceId,
523
+ clusterId: manifestArg.clusterId,
524
+ secretRolloutId: manifestArg.secretRolloutId,
525
+ secretRolloutGeneration: manifestArg.secretRolloutGeneration,
526
+ entries: manifestArg.entries.map((entryArg) => ({
527
+ secretId: entryArg.secretId,
528
+ secretVersionId: entryArg.secretVersionId,
529
+ key: entryArg.key,
530
+ environment: entryArg.environment,
531
+ owner: entryArg.owner.kind === 'service'
532
+ ? {
533
+ kind: 'service',
534
+ serviceId: entryArg.owner.serviceId,
535
+ }
536
+ : {
537
+ kind: 'secret-set',
538
+ secretSetId: entryArg.owner.secretSetId,
539
+ },
540
+ delivery: entryArg.delivery.type === 'environment'
541
+ ? {
542
+ type: 'environment',
543
+ variableName: entryArg.delivery.variableName,
544
+ }
545
+ : {
546
+ type: 'file',
547
+ targetPath: entryArg.delivery.targetPath,
548
+ uid: entryArg.delivery.uid,
549
+ gid: entryArg.delivery.gid,
550
+ mode: entryArg.delivery.mode,
551
+ },
552
+ })),
553
+ createdAt: manifestArg.createdAt,
554
+ });
555
+
556
+ /** Shared canonical digest; the digest field itself is excluded from the input. */
557
+ export const computeResolvedSecretManifestDigest = async (
558
+ manifestArg: IResolvedSecretManifest,
559
+ ): Promise<TSha256Digest> => {
560
+ const encoded = new TextEncoder().encode(createResolvedSecretManifestDigestInput(manifestArg));
561
+ const digestBuffer = await globalThis.crypto.subtle.digest('SHA-256', encoded);
562
+ const digestHex = [...new Uint8Array(digestBuffer)]
563
+ .map((byteArg) => byteArg.toString(16).padStart(2, '0'))
564
+ .join('');
565
+ return `sha256:${digestHex}` as TSha256Digest;
566
+ };
567
+
568
+ export const verifyResolvedSecretManifestDigest = async (
569
+ manifestArg: IResolvedSecretManifest,
570
+ ): Promise<boolean> => (
571
+ manifestArg.digest === await computeResolvedSecretManifestDigest(manifestArg)
572
+ );
573
+
574
+ export const validateResolvedSecretManifest = (
575
+ manifestArg: unknown,
576
+ ): string[] => {
577
+ if (!isRecord(manifestArg)) return ['secret manifest must be an object'];
578
+ const errors: string[] = [];
579
+ if (manifestArg.schemaVersion !== 1) errors.push('secret manifest schemaVersion must be 1');
580
+ for (const field of [
581
+ 'id',
582
+ 'organizationId',
583
+ 'serviceId',
584
+ 'clusterId',
585
+ 'secretRolloutId',
586
+ ] as const) {
587
+ if (!isSecretIdentifier(manifestArg[field])) {
588
+ errors.push(`secret manifest ${field} must be a canonical identifier`);
589
+ }
590
+ }
591
+ if (typeof manifestArg.digest !== 'string' || !secretDigestRegex.test(manifestArg.digest)) {
592
+ errors.push('secret manifest digest must be a canonical sha256 digest');
593
+ }
594
+ if (!Number.isSafeInteger(manifestArg.secretRolloutGeneration)
595
+ || (manifestArg.secretRolloutGeneration as number) < 1) {
596
+ errors.push('secret manifest rollout generation must be a positive integer');
597
+ }
598
+ if (!Number.isSafeInteger(manifestArg.createdAt) || (manifestArg.createdAt as number) < 0) {
599
+ errors.push('secret manifest createdAt must be a non-negative integer');
600
+ }
601
+ if (!Array.isArray(manifestArg.entries) || manifestArg.entries.length > 256) {
602
+ errors.push('secret manifest entries must be a bounded array');
603
+ return errors;
604
+ }
605
+ const keyScopes: string[] = [];
606
+ const secretIds: string[] = [];
607
+ const versionIds: string[] = [];
608
+ const environmentDeliveryTargets: string[] = [];
609
+ const fileDeliveryTargets: string[] = [];
610
+ let previousSortKey: string | undefined;
611
+ for (const [index, candidate] of manifestArg.entries.entries()) {
612
+ if (!isRecord(candidate)) {
613
+ errors.push(`secret manifest entries[${index}] must be an object`);
614
+ continue;
615
+ }
616
+ if (!isSecretIdentifier(candidate.secretId)) {
617
+ errors.push(`secret manifest entries[${index}].secretId must be canonical`);
618
+ } else {
619
+ secretIds.push(candidate.secretId);
620
+ }
621
+ if (!isSecretIdentifier(candidate.secretVersionId)) {
622
+ errors.push(`secret manifest entries[${index}].secretVersionId must be canonical`);
623
+ } else {
624
+ versionIds.push(candidate.secretVersionId);
625
+ }
626
+ if (!isSecretKey(candidate.key)) {
627
+ errors.push(`secret manifest entries[${index}].key must be canonical`);
628
+ }
629
+ if (!isSecretEnvironment(candidate.environment)) {
630
+ errors.push(`secret manifest entries[${index}].environment must be canonical`);
631
+ } else if (isSecretKey(candidate.key)) {
632
+ keyScopes.push(`${candidate.environment}\0${candidate.key}`);
633
+ }
634
+ if (!isRecord(candidate.owner)
635
+ || (
636
+ candidate.owner.kind !== 'service'
637
+ && candidate.owner.kind !== 'secret-set'
638
+ )
639
+ || (
640
+ candidate.owner.kind === 'service'
641
+ && !isSecretIdentifier(candidate.owner.serviceId)
642
+ )
643
+ || (
644
+ candidate.owner.kind === 'secret-set'
645
+ && !isSecretIdentifier(candidate.owner.secretSetId)
646
+ )) {
647
+ errors.push(`secret manifest entries[${index}].owner must be canonical`);
648
+ } else if (
649
+ candidate.owner.kind === 'service'
650
+ && isSecretIdentifier(manifestArg.serviceId)
651
+ && candidate.owner.serviceId !== manifestArg.serviceId
652
+ ) {
653
+ errors.push(
654
+ `secret manifest entries[${index}].owner serviceId must match the manifest serviceId`,
655
+ );
656
+ }
657
+ errors.push(...validateSecretDelivery(candidate.delivery).map(
658
+ (errorArg) => `secret manifest entries[${index}].${errorArg}`,
659
+ ));
660
+ if (isRecord(candidate.delivery)) {
661
+ if (
662
+ candidate.delivery.type === 'environment'
663
+ && isSecretKey(candidate.delivery.variableName)
664
+ ) {
665
+ environmentDeliveryTargets.push(candidate.delivery.variableName);
666
+ } else if (
667
+ candidate.delivery.type === 'file'
668
+ && typeof candidate.delivery.targetPath === 'string'
669
+ && validateSecretDelivery(candidate.delivery).length === 0
670
+ ) {
671
+ fileDeliveryTargets.push(candidate.delivery.targetPath);
672
+ }
673
+ }
674
+ if (isSecretKey(candidate.key) && isSecretEnvironment(candidate.environment)) {
675
+ const sortKey = `${candidate.environment}\0${candidate.key}`;
676
+ if (previousSortKey !== undefined && sortKey <= previousSortKey) {
677
+ errors.push('secret manifest entries must be uniquely sorted by environment and key');
678
+ }
679
+ previousSortKey = sortKey;
680
+ }
681
+ }
682
+ if (new Set(keyScopes).size !== keyScopes.length) {
683
+ errors.push('secret manifest final keys must be unique per environment');
684
+ }
685
+ if (new Set(secretIds).size !== secretIds.length) {
686
+ errors.push('secret manifest logical secret references must be unique');
687
+ }
688
+ if (new Set(versionIds).size !== versionIds.length) {
689
+ errors.push('secret manifest version references must be unique');
690
+ }
691
+ if (new Set(environmentDeliveryTargets).size !== environmentDeliveryTargets.length) {
692
+ errors.push('secret manifest environment delivery targets must be globally unique');
693
+ }
694
+ if (new Set(fileDeliveryTargets).size !== fileDeliveryTargets.length) {
695
+ errors.push('secret manifest file delivery targets must be globally unique');
696
+ }
697
+ return errors;
698
+ };
699
+
700
+ const validateResolvedSecretManifestReference = (
701
+ referenceArg: unknown,
702
+ expectedClusterIdArg: string,
703
+ pathArg: string,
704
+ ): string[] => {
705
+ if (!isRecord(referenceArg)
706
+ || !isSecretIdentifier(referenceArg.manifestId)
707
+ || typeof referenceArg.manifestDigest !== 'string'
708
+ || !secretDigestRegex.test(referenceArg.manifestDigest)
709
+ || referenceArg.clusterId !== expectedClusterIdArg
710
+ || !isSecretIdentifier(referenceArg.secretRolloutId)
711
+ || !Number.isSafeInteger(referenceArg.secretRolloutGeneration)
712
+ || (referenceArg.secretRolloutGeneration as number) < 1) {
713
+ return [`${pathArg} must be canonical and match its cluster`];
714
+ }
715
+ return [];
716
+ };
717
+
718
+ export const validateServiceSecretDeploymentPlan = (
719
+ planArg: unknown,
720
+ expectedTargetClusterIdsArg?: string[],
721
+ ): string[] => {
722
+ if (!isRecord(planArg)) return ['secret deployment plan must be an object'];
723
+ const errors: string[] = [];
724
+ if (planArg.schemaVersion !== 1) errors.push('secret deployment plan schemaVersion must be 1');
725
+ if (!Number.isSafeInteger(planArg.revision) || (planArg.revision as number) < 1) {
726
+ errors.push('secret deployment plan revision must be a positive integer');
727
+ }
728
+ if (!Number.isSafeInteger(planArg.updatedAt) || (planArg.updatedAt as number) < 0) {
729
+ errors.push('secret deployment plan updatedAt must be a non-negative integer');
730
+ }
731
+ if (!Array.isArray(planArg.clusterStates)
732
+ || planArg.clusterStates.length === 0
733
+ || planArg.clusterStates.length > 128) {
734
+ errors.push('secret deployment plan clusterStates must be a non-empty bounded array');
735
+ return errors;
736
+ }
737
+ const clusterIds: string[] = [];
738
+ const manifestReferencesById = new Map<string, IResolvedSecretManifestReference>();
739
+ const validateAndTrackManifestReference = (
740
+ referenceArg: unknown,
741
+ expectedClusterIdArg: string,
742
+ pathArg: string,
743
+ ): void => {
744
+ const referenceErrors = validateResolvedSecretManifestReference(
745
+ referenceArg,
746
+ expectedClusterIdArg,
747
+ pathArg,
748
+ );
749
+ errors.push(...referenceErrors);
750
+ if (referenceErrors.length > 0) return;
751
+ const reference = referenceArg as IResolvedSecretManifestReference;
752
+ const existingReference = manifestReferencesById.get(reference.manifestId);
753
+ if (
754
+ existingReference
755
+ && !resolvedSecretManifestReferencesEqual(existingReference, reference)
756
+ ) {
757
+ errors.push(
758
+ `${pathArg}.manifestId must identify one immutable manifest reference throughout the plan`,
759
+ );
760
+ return;
761
+ }
762
+ manifestReferencesById.set(reference.manifestId, reference);
763
+ };
764
+ for (const [index, candidate] of planArg.clusterStates.entries()) {
765
+ const path = `secret deployment plan clusterStates[${index}]`;
766
+ if (!isRecord(candidate) || !isSecretIdentifier(candidate.clusterId)) {
767
+ errors.push(`${path}.clusterId must be canonical`);
768
+ continue;
769
+ }
770
+ clusterIds.push(candidate.clusterId);
771
+ validateAndTrackManifestReference(
772
+ candidate.desiredManifest,
773
+ candidate.clusterId,
774
+ `${path}.desiredManifest`,
775
+ );
776
+ if (candidate.appliedManifest !== undefined) {
777
+ validateAndTrackManifestReference(
778
+ candidate.appliedManifest,
779
+ candidate.clusterId,
780
+ `${path}.appliedManifest`,
781
+ );
782
+ }
783
+ if (candidate.previousAcceptedManifest !== undefined) {
784
+ if (!isRecord(candidate.previousAcceptedManifest)
785
+ || !Number.isSafeInteger(candidate.previousAcceptedManifest.acceptedUntil)
786
+ || (candidate.previousAcceptedManifest.acceptedUntil as number) < 0) {
787
+ errors.push(`${path}.previousAcceptedManifest acceptedUntil must be non-negative`);
788
+ } else {
789
+ validateAndTrackManifestReference(
790
+ candidate.previousAcceptedManifest.manifest,
791
+ candidate.clusterId,
792
+ `${path}.previousAcceptedManifest.manifest`,
793
+ );
794
+ if (
795
+ Number.isSafeInteger(candidate.updatedAt)
796
+ && (candidate.previousAcceptedManifest.acceptedUntil as number)
797
+ < (candidate.updatedAt as number)
798
+ ) {
799
+ errors.push(`${path}.previousAcceptedManifest acceptedUntil predates its state`);
800
+ }
801
+ }
802
+ }
803
+ if (![
804
+ 'pending',
805
+ 'applying',
806
+ 'applied',
807
+ 'drifted',
808
+ 'failed',
809
+ ].includes(candidate.status as string)) {
810
+ errors.push(`${path}.status must be canonical`);
811
+ }
812
+ if (!Number.isSafeInteger(candidate.updatedAt) || (candidate.updatedAt as number) < 0) {
813
+ errors.push(`${path}.updatedAt must be a non-negative integer`);
814
+ }
815
+ if (
816
+ candidate.status === 'applied'
817
+ && !resolvedSecretManifestReferencesEqual(
818
+ candidate.appliedManifest as unknown as IResolvedSecretManifestReference,
819
+ candidate.desiredManifest as unknown as IResolvedSecretManifestReference,
820
+ )
821
+ ) {
822
+ errors.push(`${path} applied status requires the desired manifest to be applied`);
823
+ }
824
+ }
825
+ if (new Set(clusterIds).size !== clusterIds.length) {
826
+ errors.push('secret deployment plan clusterStates must contain each cluster once');
827
+ }
828
+ if (expectedTargetClusterIdsArg) {
829
+ const expected = [...new Set(expectedTargetClusterIdsArg)].sort();
830
+ if (JSON.stringify([...clusterIds].sort()) !== JSON.stringify(expected)) {
831
+ errors.push('secret deployment plan target clusters do not match the service rollout scope');
832
+ }
833
+ }
834
+ return errors;
835
+ };