@serve.zone/interfaces 23.2.0 → 24.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.
package/ts/data/secret.ts CHANGED
@@ -14,6 +14,7 @@ export type TSecretVersionLifecycleState =
14
14
  | 'active'
15
15
  | 'retired'
16
16
  | 'revoked'
17
+ | 'purge-pending'
17
18
  | 'purged';
18
19
 
19
20
  /** Retired versions remain usable only through an already-accepted manifest. */
@@ -89,6 +90,10 @@ export interface ISecretVersionRetentionReference {
89
90
  expiresAt?: number;
90
91
  }
91
92
 
93
+ export const secretVersionRetentionReferenceLimits = Object.freeze({
94
+ pageSizeMaximum: 256,
95
+ });
96
+
92
97
  /** Browser-safe logical secret metadata. It never contains value material. */
93
98
  export interface ISecretMetadata {
94
99
  id: string;
@@ -116,15 +121,61 @@ export interface ISecretMetadata {
116
121
  export interface ISecretVersionMetadata {
117
122
  id: string;
118
123
  secretId: string;
124
+ revision: number;
119
125
  lifecycleState: TSecretVersionLifecycleState;
120
126
  createdAt: number;
121
127
  createdBy: string;
122
128
  source: TSecretManagementSource;
123
129
  retiredAt?: number;
124
130
  revokedAt?: number;
131
+ purgeOperationId?: string;
125
132
  purgedAt?: number;
126
133
  }
127
134
 
135
+ /** Irreversibly erases every platform-managed copy after all references are gone. */
136
+ export interface ISecretVersionPurgeIntentV1 {
137
+ schemaVersion: 1;
138
+ mutationId: string;
139
+ secretId: string;
140
+ secretVersionId: string;
141
+ expectedSecretRevision: number;
142
+ expectedSecretVersionRevision: number;
143
+ expectedTargetSecretsRevision: number;
144
+ }
145
+
146
+ export interface ISecretVersionPurgeFailureV1 {
147
+ code: string;
148
+ retryable: boolean;
149
+ failedAt: number;
150
+ }
151
+
152
+ interface ISecretVersionPurgeOperationBaseV1 {
153
+ schemaVersion: 1;
154
+ id: string;
155
+ revision: number;
156
+ intent: ISecretVersionPurgeIntentV1;
157
+ intentDigest: TSha256Digest;
158
+ createdAt: number;
159
+ updatedAt: number;
160
+ }
161
+
162
+ export type TSecretVersionPurgeOperationV1 =
163
+ | (ISecretVersionPurgeOperationBaseV1 & {
164
+ state: 'pending' | 'erasing';
165
+ failure?: never;
166
+ purgedAt?: never;
167
+ })
168
+ | (ISecretVersionPurgeOperationBaseV1 & {
169
+ state: 'failed';
170
+ failure: ISecretVersionPurgeFailureV1;
171
+ purgedAt?: never;
172
+ })
173
+ | (ISecretVersionPurgeOperationBaseV1 & {
174
+ state: 'succeeded';
175
+ failure?: never;
176
+ purgedAt: number;
177
+ });
178
+
128
179
  export interface ISecretSetMetadata {
129
180
  id: string;
130
181
  organizationId: string;
@@ -370,12 +421,264 @@ const isSecretEnvironment = (valueArg: unknown): valueArg is string => (
370
421
  typeof valueArg === 'string' && secretEnvironmentRegex.test(valueArg)
371
422
  );
372
423
 
424
+ const isNonNegativeSafeInteger = (valueArg: unknown): valueArg is number => (
425
+ Number.isSafeInteger(valueArg) && (valueArg as number) >= 0
426
+ );
427
+
428
+ const isPositiveSafeInteger = (valueArg: unknown): valueArg is number => (
429
+ Number.isSafeInteger(valueArg) && (valueArg as number) > 0
430
+ );
431
+
373
432
  export const validateSecretMutationId = (mutationIdArg: unknown): string[] => (
374
433
  isSecretIdentifier(mutationIdArg)
375
434
  ? []
376
435
  : ['mutationId must be a canonical identifier']
377
436
  );
378
437
 
438
+ export const validateSecretVersionRetentionReferences = (
439
+ referencesArg: unknown,
440
+ ): string[] => {
441
+ if (!Array.isArray(referencesArg)
442
+ || referencesArg.length > secretVersionRetentionReferenceLimits.pageSizeMaximum) {
443
+ return ['secret version retention references must be a bounded array'];
444
+ }
445
+ const errors: string[] = [];
446
+ const kinds = new Set<TSecretVersionRetentionReferenceKind>([
447
+ 'desired-manifest',
448
+ 'applied-manifest',
449
+ 'previous-accepted-manifest',
450
+ 'rollback-manifest',
451
+ 'backup',
452
+ 'retention-window',
453
+ ]);
454
+ let previousSortKey: string | undefined;
455
+ for (const [index, referenceArg] of referencesArg.entries()) {
456
+ if (!isRecord(referenceArg)
457
+ || !hasOnlyKeys(referenceArg, ['kind', 'resourceId', 'expiresAt'])
458
+ || !kinds.has(referenceArg.kind as TSecretVersionRetentionReferenceKind)
459
+ || !isSecretIdentifier(referenceArg.resourceId)
460
+ || (referenceArg.expiresAt !== undefined
461
+ && !isNonNegativeSafeInteger(referenceArg.expiresAt))) {
462
+ errors.push(`secret version retention references[${index}] must be canonical`);
463
+ continue;
464
+ }
465
+ const sortKey = `${referenceArg.kind}\0${referenceArg.resourceId}\0${
466
+ referenceArg.expiresAt === undefined
467
+ ? ''
468
+ : String(referenceArg.expiresAt).padStart(16, '0')
469
+ }`;
470
+ if (previousSortKey !== undefined && sortKey <= previousSortKey) {
471
+ errors.push('secret version retention references must be uniquely sorted');
472
+ }
473
+ previousSortKey = sortKey;
474
+ }
475
+ return errors;
476
+ };
477
+
478
+ export const validateSecretVersionMetadata = (
479
+ versionArg: unknown,
480
+ ): string[] => {
481
+ if (!isRecord(versionArg)) return ['secret version metadata must be an object'];
482
+ const errors: string[] = [];
483
+ if (!hasOnlyKeys(versionArg, [
484
+ 'id',
485
+ 'secretId',
486
+ 'revision',
487
+ 'lifecycleState',
488
+ 'createdAt',
489
+ 'createdBy',
490
+ 'source',
491
+ 'retiredAt',
492
+ 'revokedAt',
493
+ 'purgeOperationId',
494
+ 'purgedAt',
495
+ ])) {
496
+ errors.push('secret version metadata contains unsupported fields');
497
+ }
498
+ if (!isSecretIdentifier(versionArg.id) || !isSecretIdentifier(versionArg.secretId)) {
499
+ errors.push('secret version metadata identity must be canonical');
500
+ }
501
+ if (!isPositiveSafeInteger(versionArg.revision)) {
502
+ errors.push('secret version metadata revision must be positive');
503
+ }
504
+ if (!['active', 'retired', 'revoked', 'purge-pending', 'purged']
505
+ .includes(versionArg.lifecycleState as string)) {
506
+ errors.push('secret version metadata lifecycleState must be canonical');
507
+ }
508
+ if (!isNonNegativeSafeInteger(versionArg.createdAt)
509
+ || !isSecretIdentifier(versionArg.createdBy)
510
+ || !['operator', 'appstore', 'mail', 'oidc', 'webpush', 'platform', 'system']
511
+ .includes(versionArg.source as string)) {
512
+ errors.push('secret version metadata creation fields must be canonical');
513
+ }
514
+ for (const field of ['retiredAt', 'revokedAt', 'purgedAt'] as const) {
515
+ if (versionArg[field] !== undefined && !isNonNegativeSafeInteger(versionArg[field])) {
516
+ errors.push(`secret version metadata ${field} must be a non-negative integer`);
517
+ }
518
+ }
519
+ const hasPurgeOperationId = isSecretIdentifier(versionArg.purgeOperationId);
520
+ if (versionArg.lifecycleState === 'purge-pending') {
521
+ if (!hasPurgeOperationId || versionArg.purgedAt !== undefined) {
522
+ errors.push('purge-pending secret version metadata requires only a purge operation');
523
+ }
524
+ } else if (versionArg.lifecycleState === 'purged') {
525
+ if (!hasPurgeOperationId || !isNonNegativeSafeInteger(versionArg.purgedAt)) {
526
+ errors.push('purged secret version metadata requires a purge operation and purgedAt');
527
+ }
528
+ } else if (versionArg.purgeOperationId !== undefined || versionArg.purgedAt !== undefined) {
529
+ errors.push('non-purging secret version metadata forbids purge fields');
530
+ }
531
+ return errors;
532
+ };
533
+
534
+ export const validateSecretVersionPurgeIntent = (
535
+ intentArg: unknown,
536
+ ): string[] => {
537
+ if (!isRecord(intentArg)
538
+ || !hasExactKeys(intentArg, [
539
+ 'schemaVersion',
540
+ 'mutationId',
541
+ 'secretId',
542
+ 'secretVersionId',
543
+ 'expectedSecretRevision',
544
+ 'expectedSecretVersionRevision',
545
+ 'expectedTargetSecretsRevision',
546
+ ])) {
547
+ return ['secret version purge intent must use its exact schema'];
548
+ }
549
+ const errors = validateSecretMutationId(intentArg.mutationId);
550
+ if (intentArg.schemaVersion !== 1) {
551
+ errors.push('secret version purge intent schemaVersion must be 1');
552
+ }
553
+ for (const field of ['secretId', 'secretVersionId'] as const) {
554
+ if (!isSecretIdentifier(intentArg[field])) {
555
+ errors.push(`secret version purge intent ${field} must be canonical`);
556
+ }
557
+ }
558
+ for (const field of ['expectedSecretRevision', 'expectedSecretVersionRevision'] as const) {
559
+ if (!isPositiveSafeInteger(intentArg[field])) {
560
+ errors.push(`secret version purge intent ${field} must be positive`);
561
+ }
562
+ }
563
+ if (!isNonNegativeSafeInteger(intentArg.expectedTargetSecretsRevision)) {
564
+ errors.push('secret version purge intent expectedTargetSecretsRevision must be non-negative');
565
+ }
566
+ return errors;
567
+ };
568
+
569
+ export const createSecretVersionPurgeIntentDigestInput = (
570
+ intentArg: ISecretVersionPurgeIntentV1,
571
+ ): string => JSON.stringify({
572
+ schemaVersion: intentArg.schemaVersion,
573
+ purpose: 'serve.zone/secret-version-purge',
574
+ mutationId: intentArg.mutationId,
575
+ secretId: intentArg.secretId,
576
+ secretVersionId: intentArg.secretVersionId,
577
+ expectedSecretRevision: intentArg.expectedSecretRevision,
578
+ expectedSecretVersionRevision: intentArg.expectedSecretVersionRevision,
579
+ expectedTargetSecretsRevision: intentArg.expectedTargetSecretsRevision,
580
+ });
581
+
582
+ export const computeSecretVersionPurgeIntentDigest = async (
583
+ intentArg: ISecretVersionPurgeIntentV1,
584
+ ): Promise<TSha256Digest> => {
585
+ if (validateSecretVersionPurgeIntent(intentArg).length > 0) {
586
+ throw new Error('secret version purge intent must be canonical');
587
+ }
588
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest(
589
+ 'SHA-256',
590
+ new TextEncoder().encode(createSecretVersionPurgeIntentDigestInput(intentArg)),
591
+ ));
592
+ return `sha256:${[...digest]
593
+ .map((byteArg) => byteArg.toString(16).padStart(2, '0'))
594
+ .join('')}` as TSha256Digest;
595
+ };
596
+
597
+ export const verifySecretVersionPurgeIntentDigest = async (
598
+ intentArg: ISecretVersionPurgeIntentV1,
599
+ digestArg: unknown,
600
+ ): Promise<boolean> => {
601
+ try {
602
+ return typeof digestArg === 'string'
603
+ && secretDigestRegex.test(digestArg)
604
+ && digestArg === await computeSecretVersionPurgeIntentDigest(intentArg);
605
+ } catch {
606
+ return false;
607
+ }
608
+ };
609
+
610
+ export const validateSecretVersionPurgeOperation = async (
611
+ operationArg: unknown,
612
+ ): Promise<string[]> => {
613
+ if (!isRecord(operationArg)) return ['secret version purge operation must be an object'];
614
+ const state = operationArg.state;
615
+ const expectedKeys = state === 'failed'
616
+ ? [
617
+ 'schemaVersion', 'id', 'revision', 'intent', 'intentDigest', 'state',
618
+ 'failure', 'createdAt', 'updatedAt',
619
+ ]
620
+ : state === 'succeeded'
621
+ ? [
622
+ 'schemaVersion', 'id', 'revision', 'intent', 'intentDigest', 'state',
623
+ 'purgedAt', 'createdAt', 'updatedAt',
624
+ ]
625
+ : [
626
+ 'schemaVersion', 'id', 'revision', 'intent', 'intentDigest', 'state',
627
+ 'createdAt', 'updatedAt',
628
+ ];
629
+ if (!['pending', 'erasing', 'failed', 'succeeded'].includes(state as string)
630
+ || !hasExactKeys(operationArg, expectedKeys)) {
631
+ return ['secret version purge operation must use its exact state schema'];
632
+ }
633
+ const errors = validateSecretVersionPurgeIntent(operationArg.intent);
634
+ if (operationArg.schemaVersion !== 1) {
635
+ errors.push('secret version purge operation schemaVersion must be 1');
636
+ }
637
+ if (!isSecretIdentifier(operationArg.id) || !isPositiveSafeInteger(operationArg.revision)) {
638
+ errors.push('secret version purge operation identity and revision must be canonical');
639
+ }
640
+ if (!isNonNegativeSafeInteger(operationArg.createdAt)
641
+ || !isNonNegativeSafeInteger(operationArg.updatedAt)
642
+ || (operationArg.updatedAt as number) < (operationArg.createdAt as number)) {
643
+ errors.push('secret version purge operation timestamps must be ordered');
644
+ }
645
+ if (isRecord(operationArg.intent)
646
+ && typeof operationArg.intentDigest === 'string'
647
+ && secretDigestRegex.test(operationArg.intentDigest)) {
648
+ try {
649
+ if (!await verifySecretVersionPurgeIntentDigest(
650
+ operationArg.intent as unknown as ISecretVersionPurgeIntentV1,
651
+ operationArg.intentDigest,
652
+ )) {
653
+ errors.push('secret version purge operation intentDigest does not match');
654
+ }
655
+ } catch {
656
+ errors.push('secret version purge operation intentDigest cannot be verified');
657
+ }
658
+ } else {
659
+ errors.push('secret version purge operation intentDigest must be canonical');
660
+ }
661
+ if (state === 'failed') {
662
+ if (!isRecord(operationArg.failure)
663
+ || !hasExactKeys(operationArg.failure, ['code', 'retryable', 'failedAt'])
664
+ || typeof operationArg.failure.code !== 'string'
665
+ || !/^[A-Z][A-Z0-9_]{0,127}$/.test(operationArg.failure.code)
666
+ || typeof operationArg.failure.retryable !== 'boolean'
667
+ || !isNonNegativeSafeInteger(operationArg.failure.failedAt)
668
+ || (operationArg.failure.failedAt as number) < (operationArg.createdAt as number)
669
+ || (operationArg.failure.failedAt as number) > (operationArg.updatedAt as number)) {
670
+ errors.push('secret version purge operation failure must be canonical');
671
+ }
672
+ }
673
+ if (state === 'succeeded'
674
+ && (!isNonNegativeSafeInteger(operationArg.purgedAt)
675
+ || (operationArg.purgedAt as number) < (operationArg.createdAt as number)
676
+ || (operationArg.purgedAt as number) > (operationArg.updatedAt as number))) {
677
+ errors.push('succeeded secret version purge operation requires a canonical purgedAt');
678
+ }
679
+ return errors;
680
+ };
681
+
379
682
  export const validateSecretOwner = (ownerArg: unknown): string[] => {
380
683
  if (!isRecord(ownerArg)) return ['secret owner must be an object'];
381
684
  const shapeByKind = {
@@ -446,6 +749,105 @@ export const validateSecretDelivery = (deliveryArg: unknown): string[] => {
446
749
  return errors;
447
750
  };
448
751
 
752
+ export const validateSecretMetadata = (secretArg: unknown): string[] => {
753
+ if (!isRecord(secretArg)) return ['secret metadata must be an object'];
754
+ const expectedKeys = [
755
+ 'id',
756
+ 'organizationId',
757
+ 'owner',
758
+ 'key',
759
+ 'environment',
760
+ 'name',
761
+ ...(secretArg.description === undefined ? [] : ['description']),
762
+ 'tags',
763
+ 'managementSource',
764
+ 'managementScope',
765
+ 'defaultDelivery',
766
+ 'lifecycleState',
767
+ ...(secretArg.activeVersion === undefined ? [] : ['activeVersion']),
768
+ 'versionCount',
769
+ 'revision',
770
+ 'createdAt',
771
+ 'updatedAt',
772
+ ...(secretArg.lastRotatedAt === undefined ? [] : ['lastRotatedAt']),
773
+ ];
774
+ const errors: string[] = [];
775
+ if (!hasExactKeys(secretArg, expectedKeys)) {
776
+ errors.push('secret metadata must use its exact schema');
777
+ }
778
+ if (!isSecretIdentifier(secretArg.id) || !isSecretIdentifier(secretArg.organizationId)) {
779
+ errors.push('secret metadata identity must be canonical');
780
+ }
781
+ errors.push(...validateSecretOwner(secretArg.owner));
782
+ if (!isSecretKey(secretArg.key) || !isSecretEnvironment(secretArg.environment)) {
783
+ errors.push('secret metadata key and environment must be canonical');
784
+ }
785
+ if (typeof secretArg.name !== 'string'
786
+ || secretArg.name.trim() !== secretArg.name
787
+ || secretArg.name.length < 1
788
+ || secretArg.name.length > 200
789
+ || (secretArg.description !== undefined
790
+ && (typeof secretArg.description !== 'string' || secretArg.description.length > 2000))) {
791
+ errors.push('secret metadata name and description must be bounded');
792
+ }
793
+ if (!Array.isArray(secretArg.tags) || secretArg.tags.length > 64) {
794
+ errors.push('secret metadata tags must be a bounded array');
795
+ } else {
796
+ const tagKeys: string[] = [];
797
+ for (const [index, tagArg] of secretArg.tags.entries()) {
798
+ if (!isRecord(tagArg)
799
+ || !hasExactKeys(tagArg, ['key', 'value'])
800
+ || typeof tagArg.key !== 'string'
801
+ || tagArg.key.length < 1
802
+ || tagArg.key.length > 100
803
+ || typeof tagArg.value !== 'string'
804
+ || tagArg.value.length > 500) {
805
+ errors.push(`secret metadata tags[${index}] must use the bounded key/value schema`);
806
+ } else {
807
+ tagKeys.push(tagArg.key);
808
+ }
809
+ }
810
+ if (new Set(tagKeys).size !== tagKeys.length) {
811
+ errors.push('secret metadata tag keys must be unique');
812
+ }
813
+ }
814
+ if (!['operator', 'appstore', 'mail', 'oidc', 'webpush', 'platform', 'system']
815
+ .includes(secretArg.managementSource as string)) {
816
+ errors.push('secret metadata managementSource must be canonical');
817
+ } else {
818
+ errors.push(...validateSecretManagementScope(
819
+ secretArg.managementSource as TSecretManagementSource,
820
+ secretArg.managementScope,
821
+ ));
822
+ }
823
+ errors.push(...validateSecretDelivery(secretArg.defaultDelivery));
824
+ if (!['active', 'retired', 'revoked', 'deleted'].includes(secretArg.lifecycleState as string)) {
825
+ errors.push('secret metadata lifecycleState must be canonical');
826
+ }
827
+ if (secretArg.activeVersion !== undefined) {
828
+ if (!isRecord(secretArg.activeVersion)
829
+ || !hasExactKeys(secretArg.activeVersion, ['secretId', 'secretVersionId'])
830
+ || secretArg.activeVersion.secretId !== secretArg.id
831
+ || !isSecretIdentifier(secretArg.activeVersion.secretVersionId)) {
832
+ errors.push('secret metadata activeVersion must be a canonical self-reference');
833
+ }
834
+ }
835
+ if (!isNonNegativeSafeInteger(secretArg.versionCount)
836
+ || !isPositiveSafeInteger(secretArg.revision)
837
+ || !isNonNegativeSafeInteger(secretArg.createdAt)
838
+ || !isNonNegativeSafeInteger(secretArg.updatedAt)
839
+ || (secretArg.updatedAt as number) < (secretArg.createdAt as number)) {
840
+ errors.push('secret metadata counts, revision, and timestamps must be canonical');
841
+ }
842
+ if (secretArg.lastRotatedAt !== undefined
843
+ && (!isNonNegativeSafeInteger(secretArg.lastRotatedAt)
844
+ || (secretArg.lastRotatedAt as number) < (secretArg.createdAt as number)
845
+ || (secretArg.lastRotatedAt as number) > (secretArg.updatedAt as number))) {
846
+ errors.push('secret metadata lastRotatedAt must be within its lifetime');
847
+ }
848
+ return errors;
849
+ };
850
+
449
851
  export const validateSecretValueInput = (valueInputArg: unknown): string[] => {
450
852
  if (!isRecord(valueInputArg)) return ['valueInput must be an object'];
451
853
  if (valueInputArg.mode === 'sealed') {
@@ -1306,8 +1708,6 @@ export type TSecretRecipientMetadata =
1306
1708
  | IRetiringSecretRecipientMetadata
1307
1709
  | IRevokedSecretRecipientMetadata;
1308
1710
 
1309
- export type TSecretIngressRecipient = TSecretRecipientMetadata;
1310
-
1311
1711
  export const secretRecipientLimits = Object.freeze({
1312
1712
  maximumRecipients: 64,
1313
1713
  });
@@ -1424,20 +1824,6 @@ export const validateSecretValueInputForRecipient = async (
1424
1824
  return errors;
1425
1825
  };
1426
1826
 
1427
- /** Retiring recipients open only already-issued envelopes before their deadline. */
1428
- export const canOpenIssuedSecretEnvelope = (
1429
- recipientArg: TSecretRecipientMetadata,
1430
- issuedAtArg: number,
1431
- nowArg: number,
1432
- ): boolean => Number.isSafeInteger(issuedAtArg)
1433
- && Number.isSafeInteger(nowArg)
1434
- && issuedAtArg >= recipientArg.activatedAt
1435
- && issuedAtArg <= nowArg
1436
- && (recipientArg.lifecycleState === 'active'
1437
- || (recipientArg.lifecycleState === 'retiring'
1438
- && issuedAtArg < recipientArg.acceptUntil
1439
- && nowArg < recipientArg.acceptUntil));
1440
-
1441
1827
  const canonicalSecretOwner = (ownerArg: TSecretOwner): Record<string, string> => {
1442
1828
  switch (ownerArg.kind) {
1443
1829
  case 'service': return { kind: ownerArg.kind, serviceId: ownerArg.serviceId };