@serve.zone/interfaces 32.1.0 → 32.2.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.
@@ -1,4 +1,9 @@
1
1
  import type { TSecretManagementScope } from '../data/secret.js';
2
+ import {
3
+ canonicalizeStrictJson,
4
+ createCanonicalJsonSha256Hex,
5
+ } from '../private/canonicaljson.js';
6
+ import { createStorageContractReaders } from '../private/storagecontract.js';
2
7
 
3
8
  /**
4
9
  * Backend-neutral storage vocabulary shared by App Store manifests, control
@@ -186,8 +191,12 @@ export interface IResolvedStorageBindingBase {
186
191
  /** Stable App Store storageRequests[].id. */
187
192
  requestId: string;
188
193
  /**
189
- * Canonical digest of the normalized request. Adapters use it to distinguish
190
- * an idempotent reconciliation from a migration.
194
+ * Canonical digest of the normalized request, exactly as
195
+ * `createStorageRequestSha256` states it: bare lowercase hex over the request
196
+ * alone. Adapters use it to distinguish an idempotent reconciliation from a
197
+ * migration, and a consumer re-derives it to recognise the request it asked
198
+ * for, so the control plane and the consumer call that one function rather
199
+ * than each deriving a value the other cannot reproduce.
191
200
  */
192
201
  requestDigest: string;
193
202
  kind: TStorageResourceKind;
@@ -232,3 +241,51 @@ extends IResolvedStorageBindingBase {
232
241
  export type TResolvedStorageBinding =
233
242
  | IResolvedFilesystemStorageBinding
234
243
  | IResolvedObjectStorageBinding;
244
+
245
+ export class StorageContractError extends Error {
246
+ public constructor(reasonArg: string) {
247
+ super(`storage contract error: ${reasonArg}`);
248
+ this.name = 'StorageContractError';
249
+ }
250
+ }
251
+
252
+ const fail = (reasonArg: string): never => {
253
+ throw new StorageContractError(reasonArg);
254
+ };
255
+
256
+ const { normalizeStorageRequest } = createStorageContractReaders(fail);
257
+
258
+ /**
259
+ * The exact bytes a storage request's digest is taken over: the request normalized to its contract
260
+ * shape, written as strict canonical JSON — keys in UTF-16 code-unit order, no whitespace, no
261
+ * absent member carried as `null`.
262
+ *
263
+ * Anything that is not a storage request is refused rather than hashed, because a digest of an
264
+ * unjudged body would name a request no adapter could fulfil.
265
+ */
266
+ export const canonicalizeStorageRequest = (valueArg: unknown): string => {
267
+ return canonicalizeStrictJson(
268
+ normalizeStorageRequest(valueArg, 'storage request'),
269
+ fail,
270
+ );
271
+ };
272
+
273
+ /**
274
+ * The storage request digest: bare lowercase hex SHA-256 over the canonical request, with no
275
+ * prefix and nothing but the request in the hashed value.
276
+ *
277
+ * A control plane issues this value on a binding and a consumer re-derives it from the request it
278
+ * declared; the two only agree because they call this one function. A second derivation elsewhere
279
+ * — a prefix, or a wrapper object around the request — is a different value for the same binding,
280
+ * and the consumer would keep rejecting a binding that is in fact its own.
281
+ */
282
+ export const createStorageRequestSha256 = async (
283
+ valueArg: unknown,
284
+ ): Promise<string> => {
285
+ return createCanonicalJsonSha256Hex(canonicalizeStorageRequest(valueArg), fail);
286
+ };
287
+
288
+ export {
289
+ storageRequestCanonicalDigestGoldenVectors,
290
+ type IStorageRequestCanonicalDigestGoldenVector,
291
+ } from './storage.golden.js';
@@ -8,6 +8,10 @@ import {
8
8
  deepFreezeValue,
9
9
  strictCanonicalJsonRules,
10
10
  } from '../private/canonicaljson.js';
11
+ import {
12
+ createStorageContractReaders,
13
+ storageContractStringLimits,
14
+ } from '../private/storagecontract.js';
11
15
  import type {
12
16
  IResolvedObjectStorageBinding,
13
17
  IResolvedStoragePolicyRef,
@@ -16,11 +20,13 @@ import type {
16
20
 
17
21
  export const storageMigrationContractLimits = Object.freeze({
18
22
  maximumControlJsonBytes: 256 * 1024,
19
- maximumConfiguredStringBytes: 2048,
23
+ maximumConfiguredStringBytes:
24
+ storageContractStringLimits.maximumConfiguredStringBytes,
20
25
  maximumIssueMessageBytes: 4096,
21
26
  maximumAbortReasonBytes: 4096,
22
- maximumEnvironmentKeyBytes: 256,
23
- maximumSecretPathBytes: 2048,
27
+ maximumEnvironmentKeyBytes:
28
+ storageContractStringLimits.maximumEnvironmentKeyBytes,
29
+ maximumSecretPathBytes: storageContractStringLimits.maximumSecretPathBytes,
24
30
  });
25
31
 
26
32
  export const storageMigrationCanonicalJsonRules = strictCanonicalJsonRules;
@@ -408,9 +414,28 @@ const fail = (reasonArg: string): never => {
408
414
  throw new StorageMigrationContractError(reasonArg);
409
415
  };
410
416
 
411
- const utf8ByteLength = (valueArg: string): number => {
412
- return new TextEncoder().encode(valueArg).byteLength;
413
- };
417
+ /**
418
+ * The storage contract's readers, reporting every refusal as a migration contract error. The
419
+ * request a migration targets is the same request a control plane reconciles, so it is judged by
420
+ * the same rules and, through them, digests to the same bytes.
421
+ */
422
+ const {
423
+ utf8ByteLength,
424
+ readRecord,
425
+ readDenseArray,
426
+ assertKeys,
427
+ assertExactKeys,
428
+ readString,
429
+ readIdentifier,
430
+ readSha256,
431
+ readSafeInteger,
432
+ readBoolean,
433
+ readLiteral,
434
+ readCapacityQuantity,
435
+ capacityQuantityBytes,
436
+ normalizeDelivery,
437
+ normalizeObjectStorageRequest,
438
+ } = createStorageContractReaders(fail);
414
439
 
415
440
  /**
416
441
  * Enforce this against Content-Length and the running request byte count
@@ -427,200 +452,6 @@ export const assertStorageMigrationControlIngressByteLength = (
427
452
  );
428
453
  };
429
454
 
430
- const readRecord = (
431
- valueArg: unknown,
432
- fieldNameArg: string,
433
- ): Record<string, unknown> => {
434
- if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) {
435
- return fail(`${fieldNameArg} must be a plain object`);
436
- }
437
- const prototype = Object.getPrototypeOf(valueArg);
438
- if (prototype !== Object.prototype && prototype !== null) {
439
- return fail(`${fieldNameArg} must be a plain object`);
440
- }
441
- const record = valueArg as Record<string, unknown>;
442
- const ownKeys = Reflect.ownKeys(record);
443
- if (ownKeys.some((keyArg) => typeof keyArg !== 'string')) {
444
- return fail(`${fieldNameArg} must not contain symbol keys`);
445
- }
446
- for (const key of ownKeys as string[]) {
447
- const descriptor = Object.getOwnPropertyDescriptor(record, key);
448
- if (
449
- !descriptor ||
450
- !descriptor.enumerable ||
451
- !Object.hasOwn(descriptor, 'value') ||
452
- descriptor.value === undefined
453
- ) {
454
- return fail(
455
- `${fieldNameArg} must contain enumerable JSON data properties only`,
456
- );
457
- }
458
- }
459
- return record;
460
- };
461
-
462
- const readDenseArray = (
463
- valueArg: unknown,
464
- fieldNameArg: string,
465
- minimumLengthArg: number,
466
- maximumLengthArg: number,
467
- ): unknown[] => {
468
- if (
469
- !Array.isArray(valueArg) ||
470
- Object.getPrototypeOf(valueArg) !== Array.prototype
471
- ) {
472
- return fail(`${fieldNameArg} must be a dense plain array`);
473
- }
474
- if (
475
- valueArg.length < minimumLengthArg ||
476
- valueArg.length > maximumLengthArg
477
- ) {
478
- return fail(
479
- `${fieldNameArg} must contain between ${minimumLengthArg} and ${maximumLengthArg} entries`,
480
- );
481
- }
482
- for (let index = 0; index < valueArg.length; index++) {
483
- if (!Object.hasOwn(valueArg, index)) {
484
- return fail(`${fieldNameArg} must not contain sparse entries`);
485
- }
486
- const descriptor = Object.getOwnPropertyDescriptor(valueArg, String(index));
487
- if (
488
- !descriptor ||
489
- !descriptor.enumerable ||
490
- !Object.hasOwn(descriptor, 'value') ||
491
- descriptor.value === undefined
492
- ) {
493
- return fail(
494
- `${fieldNameArg} must contain enumerable JSON data entries only`,
495
- );
496
- }
497
- }
498
- for (const key of Reflect.ownKeys(valueArg)) {
499
- if (key === 'length') continue;
500
- if (
501
- typeof key !== 'string' ||
502
- !/^(?:0|[1-9][0-9]*)$/.test(key) ||
503
- Number(key) >= valueArg.length
504
- ) {
505
- return fail(`${fieldNameArg} must not contain extra properties`);
506
- }
507
- }
508
- return valueArg;
509
- };
510
-
511
- const assertKeys = (
512
- recordArg: Record<string, unknown>,
513
- requiredKeysArg: readonly string[],
514
- optionalKeysArg: readonly string[],
515
- fieldNameArg: string,
516
- ): void => {
517
- const actualKeys = Object.keys(recordArg).sort();
518
- const allowedKeys = new Set([...requiredKeysArg, ...optionalKeysArg]);
519
- if (
520
- requiredKeysArg.some((keyArg) => !Object.hasOwn(recordArg, keyArg)) ||
521
- actualKeys.some((keyArg) => !allowedKeys.has(keyArg))
522
- ) {
523
- fail(`${fieldNameArg} does not match its exact schema`);
524
- }
525
- };
526
-
527
- const assertExactKeys = (
528
- recordArg: Record<string, unknown>,
529
- expectedKeysArg: readonly string[],
530
- fieldNameArg: string,
531
- ): void => {
532
- assertKeys(recordArg, expectedKeysArg, [], fieldNameArg);
533
- };
534
-
535
- const readString = (
536
- valueArg: unknown,
537
- fieldNameArg: string,
538
- maximumBytesArg: number =
539
- storageMigrationContractLimits.maximumConfiguredStringBytes,
540
- ): string => {
541
- if (
542
- typeof valueArg !== 'string' ||
543
- valueArg.length === 0 ||
544
- utf8ByteLength(valueArg) > maximumBytesArg ||
545
- /[\u0000-\u001f\u007f]/u.test(valueArg)
546
- ) {
547
- return fail(`${fieldNameArg} must be a bounded non-empty JSON string`);
548
- }
549
- for (let index = 0; index < valueArg.length; index++) {
550
- const codeUnit = valueArg.charCodeAt(index);
551
- if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
552
- const following = valueArg.charCodeAt(index + 1);
553
- if (following < 0xdc00 || following > 0xdfff) {
554
- return fail(`${fieldNameArg} contains an unpaired UTF-16 surrogate`);
555
- }
556
- index++;
557
- } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
558
- return fail(`${fieldNameArg} contains an unpaired UTF-16 surrogate`);
559
- }
560
- }
561
- return valueArg;
562
- };
563
-
564
- const canonicalIdPattern = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,191}$/;
565
- const environmentKeyPattern = /^[A-Z_][A-Z0-9_]{0,255}$/;
566
- const sha256Pattern = /^[a-f0-9]{64}$/;
567
- const capacityPattern = /^[1-9][0-9]*(?:KiB|MiB|GiB|TiB)$/;
568
-
569
- const readIdentifier = (valueArg: unknown, fieldNameArg: string): string => {
570
- if (typeof valueArg !== 'string' || !canonicalIdPattern.test(valueArg)) {
571
- return fail(`${fieldNameArg} must be a canonical identifier`);
572
- }
573
- return valueArg;
574
- };
575
-
576
- const readSha256 = (valueArg: unknown, fieldNameArg: string): string => {
577
- if (typeof valueArg !== 'string' || !sha256Pattern.test(valueArg)) {
578
- return fail(`${fieldNameArg} must be a bare lowercase 64-hex SHA-256 digest`);
579
- }
580
- return valueArg;
581
- };
582
-
583
- const readSafeInteger = (
584
- valueArg: unknown,
585
- fieldNameArg: string,
586
- minimumArg: number,
587
- maximumArg = Number.MAX_SAFE_INTEGER,
588
- ): number => {
589
- if (
590
- typeof valueArg !== 'number' ||
591
- !Number.isSafeInteger(valueArg) ||
592
- Object.is(valueArg, -0) ||
593
- valueArg < minimumArg ||
594
- valueArg > maximumArg
595
- ) {
596
- return fail(
597
- `${fieldNameArg} must be a safe integer between ${minimumArg} and ${maximumArg}`,
598
- );
599
- }
600
- return valueArg;
601
- };
602
-
603
- const readBoolean = (valueArg: unknown, fieldNameArg: string): boolean => {
604
- if (typeof valueArg !== 'boolean') {
605
- return fail(`${fieldNameArg} must be a boolean`);
606
- }
607
- return valueArg;
608
- };
609
-
610
- const readLiteral = <TLiteral extends string>(
611
- valueArg: unknown,
612
- allowedArg: readonly TLiteral[],
613
- fieldNameArg: string,
614
- ): TLiteral => {
615
- if (
616
- typeof valueArg !== 'string' ||
617
- !allowedArg.includes(valueArg as TLiteral)
618
- ) {
619
- return fail(`${fieldNameArg} is outside the supported values`);
620
- }
621
- return valueArg as TLiteral;
622
- };
623
-
624
455
  const canonicalize = (valueArg: unknown): string => {
625
456
  return canonicalizeStrictJson(valueArg, fail);
626
457
  };
@@ -777,50 +608,6 @@ const normalizeStorageClass = (
777
608
  };
778
609
  };
779
610
 
780
- const readCapacityQuantity = (
781
- valueArg: unknown,
782
- fieldNameArg: string,
783
- ): string => {
784
- if (typeof valueArg !== 'string' || !capacityPattern.test(valueArg)) {
785
- return fail(`${fieldNameArg} must be a canonical base-2 capacity quantity`);
786
- }
787
- const numericPart = valueArg.replace(/(?:KiB|MiB|GiB|TiB)$/, '');
788
- if (!Number.isSafeInteger(Number(numericPart))) {
789
- return fail(`${fieldNameArg} exceeds the safe integer quantity range`);
790
- }
791
- return valueArg;
792
- };
793
-
794
- const capacityQuantityBytes = (valueArg: string): bigint => {
795
- const units: Record<string, bigint> = {
796
- KiB: 1024n,
797
- MiB: 1024n ** 2n,
798
- GiB: 1024n ** 3n,
799
- TiB: 1024n ** 4n,
800
- };
801
- const unit = valueArg.slice(-3);
802
- return BigInt(valueArg.slice(0, -3)) * units[unit];
803
- };
804
-
805
- const normalizeCapacityRequest = (
806
- valueArg: unknown,
807
- fieldNameArg: string,
808
- ): NonNullable<IAppStoreObjectStorageRequest['capacity']> => {
809
- const value = readRecord(valueArg, fieldNameArg);
810
- assertKeys(value, ['request'], ['limit'], fieldNameArg);
811
- const request = readCapacityQuantity(value.request, `${fieldNameArg}.request`);
812
- const limit = Object.hasOwn(value, 'limit')
813
- ? readCapacityQuantity(value.limit, `${fieldNameArg}.limit`)
814
- : undefined;
815
- if (limit && capacityQuantityBytes(limit) < capacityQuantityBytes(request)) {
816
- return fail(`${fieldNameArg}.limit must be at least request`);
817
- }
818
- return {
819
- request,
820
- ...(limit ? { limit } : {}),
821
- };
822
- };
823
-
824
611
  const normalizeResolvedCapacity = (
825
612
  valueArg: unknown,
826
613
  fieldNameArg: string,
@@ -867,102 +654,6 @@ const normalizeResolvedCapacity = (
867
654
  };
868
655
  };
869
656
 
870
- const readEnvironmentKey = (
871
- valueArg: unknown,
872
- fieldNameArg: string,
873
- ): string => {
874
- if (
875
- typeof valueArg !== 'string' ||
876
- utf8ByteLength(valueArg) >
877
- storageMigrationContractLimits.maximumEnvironmentKeyBytes ||
878
- !environmentKeyPattern.test(valueArg)
879
- ) {
880
- return fail(`${fieldNameArg} must be a canonical environment key`);
881
- }
882
- return valueArg;
883
- };
884
-
885
- const normalizeDelivery = (
886
- valueArg: unknown,
887
- fieldNameArg: string,
888
- ): IResolvedObjectStorageBinding['delivery'] => {
889
- const value = readRecord(valueArg, fieldNameArg);
890
- if (value.type === 'launcher-environment') {
891
- assertExactKeys(value, ['type', 'keys'], fieldNameArg);
892
- const keys = readRecord(value.keys, `${fieldNameArg}.keys`);
893
- assertKeys(
894
- keys,
895
- ['endpoint', 'bucket', 'region', 'accessKeyId', 'secretAccessKey'],
896
- ['sessionToken'],
897
- `${fieldNameArg}.keys`,
898
- );
899
- const normalizedKeys = {
900
- endpoint: readEnvironmentKey(
901
- keys.endpoint,
902
- `${fieldNameArg}.keys.endpoint`,
903
- ),
904
- bucket: readEnvironmentKey(keys.bucket, `${fieldNameArg}.keys.bucket`),
905
- region: readEnvironmentKey(keys.region, `${fieldNameArg}.keys.region`),
906
- accessKeyId: readEnvironmentKey(
907
- keys.accessKeyId,
908
- `${fieldNameArg}.keys.accessKeyId`,
909
- ),
910
- secretAccessKey: readEnvironmentKey(
911
- keys.secretAccessKey,
912
- `${fieldNameArg}.keys.secretAccessKey`,
913
- ),
914
- ...(Object.hasOwn(keys, 'sessionToken')
915
- ? {
916
- sessionToken: readEnvironmentKey(
917
- keys.sessionToken,
918
- `${fieldNameArg}.keys.sessionToken`,
919
- ),
920
- }
921
- : {}),
922
- };
923
- if (
924
- new Set(Object.values(normalizedKeys)).size !==
925
- Object.keys(normalizedKeys).length
926
- ) {
927
- return fail(`${fieldNameArg}.keys values must be distinct`);
928
- }
929
- return { type: 'launcher-environment', keys: normalizedKeys };
930
- }
931
- if (value.type === 'file') {
932
- assertExactKeys(value, ['type', 'targetPath', 'format', 'uid', 'gid', 'mode'], fieldNameArg);
933
- const targetPath = readString(
934
- value.targetPath,
935
- `${fieldNameArg}.targetPath`,
936
- storageMigrationContractLimits.maximumSecretPathBytes,
937
- );
938
- if (
939
- !targetPath.startsWith('/run/secrets/') ||
940
- targetPath.includes('\\') ||
941
- targetPath.split('/').some((segmentArg) =>
942
- segmentArg === '.' || segmentArg === '..'
943
- )
944
- ) {
945
- return fail(
946
- `${fieldNameArg}.targetPath must be a canonical path below /run/secrets`,
947
- );
948
- }
949
- if (value.format !== 'servezone-object-storage') {
950
- return fail(`${fieldNameArg}.format is unsupported`);
951
- }
952
- return {
953
- type: 'file',
954
- targetPath,
955
- format: 'servezone-object-storage',
956
- uid: readSafeInteger(value.uid, `${fieldNameArg}.uid`, 0, 2_147_483_647),
957
- gid: readSafeInteger(value.gid, `${fieldNameArg}.gid`, 0, 2_147_483_647),
958
- mode: value.mode === 0o400 || value.mode === 0o440
959
- ? value.mode
960
- : fail(`${fieldNameArg}.mode must be 0400 or 0440`),
961
- };
962
- }
963
- return fail(`${fieldNameArg}.type is unsupported`);
964
- };
965
-
966
657
  const normalizeCredentialManagementScope = (
967
658
  valueArg: unknown,
968
659
  fieldNameArg: string,
@@ -974,106 +665,6 @@ const normalizeCredentialManagementScope = (
974
665
  return value as IResolvedObjectStorageBinding['credentialManagementScope'];
975
666
  };
976
667
 
977
- const normalizeObjectStorageProtection = (
978
- valueArg: unknown,
979
- fieldNameArg: string,
980
- ): NonNullable<IAppStoreObjectStorageRequest['protection']> => {
981
- const value = readRecord(valueArg, fieldNameArg);
982
- assertKeys(
983
- value,
984
- [],
985
- ['backup', 'versioning', 'retentionDays'],
986
- fieldNameArg,
987
- );
988
- if (Object.keys(value).length === 0) {
989
- return fail(`${fieldNameArg} must not be empty`);
990
- }
991
- return {
992
- ...(Object.hasOwn(value, 'backup')
993
- ? {
994
- backup: value.backup === 'required'
995
- ? 'required' as const
996
- : fail(`${fieldNameArg}.backup must be required`),
997
- }
998
- : {}),
999
- ...(Object.hasOwn(value, 'versioning')
1000
- ? {
1001
- versioning: value.versioning === 'required'
1002
- ? 'required' as const
1003
- : fail(`${fieldNameArg}.versioning must be required`),
1004
- }
1005
- : {}),
1006
- ...(Object.hasOwn(value, 'retentionDays')
1007
- ? {
1008
- retentionDays: readSafeInteger(
1009
- value.retentionDays,
1010
- `${fieldNameArg}.retentionDays`,
1011
- 1,
1012
- 365_000,
1013
- ),
1014
- }
1015
- : {}),
1016
- };
1017
- };
1018
-
1019
- const normalizeObjectStorageRequest = (
1020
- valueArg: unknown,
1021
- fieldNameArg: string,
1022
- ): IAppStoreObjectStorageRequest => {
1023
- const value = readRecord(valueArg, fieldNameArg);
1024
- assertKeys(
1025
- value,
1026
- [
1027
- 'id',
1028
- 'kind',
1029
- 'storageClass',
1030
- 'accessMode',
1031
- 'reclaimPolicy',
1032
- 'delivery',
1033
- ],
1034
- ['capacity', 'protection'],
1035
- fieldNameArg,
1036
- );
1037
- if (value.kind !== 'objectStorage') {
1038
- return fail(`${fieldNameArg}.kind must be objectStorage`);
1039
- }
1040
- return {
1041
- id: readIdentifier(value.id, `${fieldNameArg}.id`),
1042
- kind: 'objectStorage',
1043
- storageClass: readIdentifier(
1044
- value.storageClass,
1045
- `${fieldNameArg}.storageClass`,
1046
- ),
1047
- accessMode: readLiteral(
1048
- value.accessMode,
1049
- ['readOnly', 'readWrite'] as const,
1050
- `${fieldNameArg}.accessMode`,
1051
- ),
1052
- reclaimPolicy: readLiteral(
1053
- value.reclaimPolicy,
1054
- ['retain', 'delete'] as const,
1055
- `${fieldNameArg}.reclaimPolicy`,
1056
- ),
1057
- delivery: normalizeDelivery(value.delivery, `${fieldNameArg}.delivery`),
1058
- ...(Object.hasOwn(value, 'capacity')
1059
- ? {
1060
- capacity: normalizeCapacityRequest(
1061
- value.capacity,
1062
- `${fieldNameArg}.capacity`,
1063
- ),
1064
- }
1065
- : {}),
1066
- ...(Object.hasOwn(value, 'protection')
1067
- ? {
1068
- protection: normalizeObjectStorageProtection(
1069
- value.protection,
1070
- `${fieldNameArg}.protection`,
1071
- ),
1072
- }
1073
- : {}),
1074
- };
1075
- };
1076
-
1077
668
  const normalizeCapabilities = (
1078
669
  valueArg: unknown,
1079
670
  fieldNameArg: string,