@serve.zone/interfaces 32.1.0 → 32.3.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.
@@ -6,7 +6,11 @@ import {
6
6
  canonicalizeStrictJson,
7
7
  createCanonicalJsonSha256Hex,
8
8
  } from '../private/canonicaljson.js';
9
- import { hasExactKeys } from '../private/dataproperties.js';
9
+ import {
10
+ hasExactKeys,
11
+ readDataElements,
12
+ readDataProperties,
13
+ } from '../private/dataproperties.js';
10
14
 
11
15
  export interface IClusterNodeMetrics {
12
16
  cpuUsagePercent: number;
@@ -610,6 +614,29 @@ export const validateSparkSwarmObservation = async (
610
614
  }
611
615
  };
612
616
 
617
+ /**
618
+ * Which node is reporting, in the two members every Spark route body carries.
619
+ *
620
+ * All four routes authenticate the same reporter from the same pair, so the rule lives here: a
621
+ * copy per route is how one route ends up admitting a bearer another refuses.
622
+ */
623
+ const validateSparkReporterIdentity = (
624
+ requestArg: Record<string, unknown>,
625
+ labelArg: string,
626
+ ): string[] => {
627
+ const errors: string[] = [];
628
+ if (!isRuntimeIdentifier(requestArg.nodeId)) {
629
+ errors.push(`${labelArg} nodeId must be canonical`);
630
+ }
631
+ if (typeof requestArg.nodeToken !== 'string'
632
+ || requestArg.nodeToken.length < 1
633
+ || requestArg.nodeToken.length > 512
634
+ || requestArg.nodeToken.trim() !== requestArg.nodeToken) {
635
+ errors.push(`${labelArg} nodeToken must be a bounded opaque token`);
636
+ }
637
+ return errors;
638
+ };
639
+
613
640
  export const validateSparkSwarmObservationRequest = async (
614
641
  requestArg: unknown,
615
642
  ): Promise<string[]> => {
@@ -632,15 +659,10 @@ export const validateSparkSwarmObservationRequest = async (
632
659
  'Spark Swarm observation request protocol',
633
660
  );
634
661
  errors.push(...await validateSparkSwarmObservation(requestArg.observation));
635
- if (!isRuntimeIdentifier(requestArg.nodeId)) {
636
- errors.push('Spark Swarm observation request nodeId must be canonical');
637
- }
638
- if (typeof requestArg.nodeToken !== 'string'
639
- || requestArg.nodeToken.length < 1
640
- || requestArg.nodeToken.length > 512
641
- || requestArg.nodeToken.trim() !== requestArg.nodeToken) {
642
- errors.push('Spark Swarm observation request nodeToken must be a bounded opaque token');
643
- }
662
+ errors.push(...validateSparkReporterIdentity(
663
+ requestArg,
664
+ 'Spark Swarm observation request',
665
+ ));
644
666
  return errors;
645
667
  } catch {
646
668
  return ['Spark Swarm observation request must be safely inspectable'];
@@ -735,6 +757,431 @@ export const validateSparkSwarmObservationResponse = async (
735
757
  }
736
758
  };
737
759
 
760
+ /** A runtime description or a service name: bounded, and free of bytes JSON text cannot carry. */
761
+ const maximumSparkRuntimeTextLength = 512;
762
+ /** An operator-facing message, which may hold line breaks but is still bounded. */
763
+ const maximumSparkMessageTextLength = 2048;
764
+ /** `sparkSwarmNodeContracts.actionResult` states what a server keeps of an action's output. */
765
+ const maximumSparkActionResultTextLength = 4000;
766
+ /**
767
+ * The route's own byte bound refuses an oversized runtime description first; this stops an
768
+ * in-process caller from making the validator walk a list no route could have delivered.
769
+ */
770
+ const maximumSparkRuntimeServices = 64;
771
+
772
+ const sparkCloudlyConnectionStatuses = new Set<TSparkCloudlyConnectionStatus>([
773
+ 'not-configured',
774
+ 'connecting',
775
+ 'connected',
776
+ 'failed',
777
+ ]);
778
+ const sparkNodeModes = new Set<TSparkNodeMode>(['cloudly', 'coreflow-node']);
779
+ const sparkActionResultStatuses = new Set<TNodeActionStatus>([
780
+ 'running',
781
+ 'succeeded',
782
+ 'failed',
783
+ ]);
784
+
785
+ const isBoundedText = (
786
+ valueArg: unknown,
787
+ maximumLengthArg = maximumSparkRuntimeTextLength,
788
+ ): valueArg is string => (
789
+ typeof valueArg === 'string'
790
+ && valueArg.length > 0
791
+ && valueArg.length <= maximumLengthArg
792
+ && !/[\u0000-\u001f\u007f]/u.test(valueArg)
793
+ );
794
+
795
+ /** Message text a node reports: line breaks and tabs are its content, other control bytes are not. */
796
+ const isBoundedMessageText = (
797
+ valueArg: unknown,
798
+ maximumLengthArg: number,
799
+ ): valueArg is string => (
800
+ typeof valueArg === 'string'
801
+ && valueArg.length <= maximumLengthArg
802
+ && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(valueArg)
803
+ );
804
+
805
+ const isNonNegativeNumber = (
806
+ valueArg: unknown,
807
+ maximumArg = Number.MAX_SAFE_INTEGER,
808
+ ): valueArg is number => (
809
+ typeof valueArg === 'number'
810
+ && Number.isFinite(valueArg)
811
+ && !Object.is(valueArg, -0)
812
+ && valueArg >= 0
813
+ && valueArg <= maximumArg
814
+ );
815
+
816
+ const isNonNegativeSafeInteger = (valueArg: unknown): valueArg is number => (
817
+ Number.isSafeInteger(valueArg) && !Object.is(valueArg, -0) && (valueArg as number) >= 0
818
+ );
819
+
820
+ /** A record a Spark body states, detached from the caller's object, or `null` with the reason. */
821
+ interface ISparkRecordReading {
822
+ record: Record<string, unknown> | null;
823
+ errors: string[];
824
+ }
825
+
826
+ /**
827
+ * One level of a Spark body: own enumerable data properties only, then the exact key set.
828
+ *
829
+ * The heartbeat, metrics-sample and action-result bodies are judged member by member rather than
830
+ * as canonical bytes, because only the observation is digested and those three legitimately carry
831
+ * fractional CPU, memory and rate observations that the digest's integer encoding would refuse.
832
+ * What the canonical reader also gave — a body that cannot run code or answer differently on a
833
+ * second read — is kept here by copying the data properties away from the caller's object before
834
+ * any member is judged.
835
+ */
836
+ const readSparkRecord = (
837
+ valueArg: unknown,
838
+ labelArg: string,
839
+ requiredKeysArg: readonly string[],
840
+ optionalKeysArg: readonly string[] = [],
841
+ ): ISparkRecordReading => {
842
+ const record = readDataProperties(valueArg);
843
+ if (record === null) {
844
+ return { record: null, errors: [`${labelArg} must contain plain JSON data properties`] };
845
+ }
846
+ if (!hasExactKeys(record, requiredKeysArg, optionalKeysArg)) {
847
+ return { record: null, errors: [`${labelArg} must use its exact schema`] };
848
+ }
849
+ return { record, errors: [] };
850
+ };
851
+
852
+ /**
853
+ * The envelope a Spark route body states before its payload: the exact key set, the reporter's
854
+ * protocol offer read by name, and the reporter's identity — in that order, because a server
855
+ * answers an unreadable offer before it looks at anything the body claims about a node.
856
+ */
857
+ const readSparkRouteEnvelope = (
858
+ requestArg: unknown,
859
+ labelArg: string,
860
+ requiredKeysArg: readonly string[],
861
+ optionalKeysArg: readonly string[] = [],
862
+ ): ISparkRecordReading => {
863
+ const reading = readSparkRecord(requestArg, labelArg, requiredKeysArg, optionalKeysArg);
864
+ if (reading.record === null) {
865
+ return reading;
866
+ }
867
+ return {
868
+ record: reading.record,
869
+ errors: [
870
+ ...validateProtocolOfferOn(reading.record, `${labelArg} protocol`),
871
+ ...validateSparkReporterIdentity(reading.record, labelArg),
872
+ ],
873
+ };
874
+ };
875
+
876
+ /**
877
+ * One reason per member, collected rather than thrown.
878
+ *
879
+ * A Spark route answers a refusal before the node is authenticated, so the reporter only ever
880
+ * learns which member of its own body was wrong; naming that member is what lets an operator fix
881
+ * a node without reading the server's source.
882
+ */
883
+ const createSparkFieldErrors = (
884
+ recordArg: Record<string, unknown>,
885
+ labelArg: string,
886
+ ) => {
887
+ const errors: string[] = [];
888
+ return {
889
+ errors,
890
+ number: (keyArg: string, maximumArg?: number): void => {
891
+ if (!isNonNegativeNumber(recordArg[keyArg], maximumArg)) {
892
+ errors.push(`${labelArg} ${keyArg} must be a bounded non-negative number`);
893
+ }
894
+ },
895
+ percentage: (keyArg: string): void => {
896
+ if (!isNonNegativeNumber(recordArg[keyArg], 100)) {
897
+ errors.push(`${labelArg} ${keyArg} must be a percentage between 0 and 100`);
898
+ }
899
+ },
900
+ count: (keyArg: string): void => {
901
+ if (!isNonNegativeSafeInteger(recordArg[keyArg])) {
902
+ errors.push(`${labelArg} ${keyArg} must be a non-negative safe integer`);
903
+ }
904
+ },
905
+ instant: (keyArg: string): void => {
906
+ if (!isPositiveSafeInteger(recordArg[keyArg])) {
907
+ errors.push(`${labelArg} ${keyArg} must be a positive safe integer timestamp`);
908
+ }
909
+ },
910
+ text: (keyArg: string): void => {
911
+ if (!isBoundedText(recordArg[keyArg])) {
912
+ errors.push(`${labelArg} ${keyArg} must be bounded non-empty text`);
913
+ }
914
+ },
915
+ messageText: (keyArg: string, maximumLengthArg: number): void => {
916
+ if (!isBoundedMessageText(recordArg[keyArg], maximumLengthArg)) {
917
+ errors.push(`${labelArg} ${keyArg} must be bounded message text`);
918
+ }
919
+ },
920
+ identifier: (keyArg: string): void => {
921
+ if (!isRuntimeIdentifier(recordArg[keyArg])) {
922
+ errors.push(`${labelArg} ${keyArg} must be canonical`);
923
+ }
924
+ },
925
+ boolean: (keyArg: string): void => {
926
+ if (typeof recordArg[keyArg] !== 'boolean') {
927
+ errors.push(`${labelArg} ${keyArg} must be a boolean`);
928
+ }
929
+ },
930
+ member: <TMember extends string>(keyArg: string, allowedArg: ReadonlySet<TMember>): void => {
931
+ if (!allowedArg.has(recordArg[keyArg] as TMember)) {
932
+ errors.push(`${labelArg} ${keyArg} is outside the supported values`);
933
+ }
934
+ },
935
+ /** Runs `checkArg` only when the optional member is present at all. */
936
+ optional: (keyArg: string, checkArg: (keyArg: string) => void): void => {
937
+ if (recordArg[keyArg] !== undefined) {
938
+ checkArg(keyArg);
939
+ }
940
+ },
941
+ };
942
+ };
943
+
944
+ const validateSparkNodeMetrics = (valueArg: unknown, labelArg: string): string[] => {
945
+ const reading = readSparkRecord(valueArg, labelArg, [
946
+ 'cpuUsagePercent',
947
+ 'memoryUsedMB',
948
+ 'memoryAvailableMB',
949
+ 'diskUsedGB',
950
+ 'diskAvailableGB',
951
+ 'containerCount',
952
+ 'timestamp',
953
+ ]);
954
+ if (reading.record === null) {
955
+ return reading.errors;
956
+ }
957
+ const field = createSparkFieldErrors(reading.record, labelArg);
958
+ field.percentage('cpuUsagePercent');
959
+ field.number('memoryUsedMB');
960
+ field.number('memoryAvailableMB');
961
+ field.number('diskUsedGB');
962
+ field.number('diskAvailableGB');
963
+ field.count('containerCount');
964
+ field.instant('timestamp');
965
+ return field.errors;
966
+ };
967
+
968
+ const validateNodeMetricsSample = (valueArg: unknown, labelArg: string): string[] => {
969
+ const reading = readSparkRecord(
970
+ valueArg,
971
+ labelArg,
972
+ ['timestamp', 'cpuUsagePercent', 'memoryUsedMB', 'memoryAvailableMB'],
973
+ ['networkRxBytesPerSec', 'networkTxBytesPerSec'],
974
+ );
975
+ if (reading.record === null) {
976
+ return reading.errors;
977
+ }
978
+ const field = createSparkFieldErrors(reading.record, labelArg);
979
+ field.instant('timestamp');
980
+ field.percentage('cpuUsagePercent');
981
+ field.number('memoryUsedMB');
982
+ field.number('memoryAvailableMB');
983
+ field.optional('networkRxBytesPerSec', field.number);
984
+ field.optional('networkTxBytesPerSec', field.number);
985
+ return field.errors;
986
+ };
987
+
988
+ const validateServeZoneServiceRuntimeInfo = (
989
+ valueArg: unknown,
990
+ labelArg: string,
991
+ ): string[] => {
992
+ const reading = readSparkRecord(
993
+ valueArg,
994
+ labelArg,
995
+ ['name', 'running', 'checkedAt'],
996
+ [
997
+ 'serviceId',
998
+ 'image',
999
+ 'desiredImage',
1000
+ 'imageVersion',
1001
+ 'runningImageId',
1002
+ 'runningTaskCount',
1003
+ 'updatedAt',
1004
+ 'error',
1005
+ ],
1006
+ );
1007
+ if (reading.record === null) {
1008
+ return reading.errors;
1009
+ }
1010
+ const field = createSparkFieldErrors(reading.record, labelArg);
1011
+ field.text('name');
1012
+ field.boolean('running');
1013
+ field.instant('checkedAt');
1014
+ field.optional('serviceId', field.text);
1015
+ field.optional('image', field.text);
1016
+ field.optional('desiredImage', field.text);
1017
+ field.optional('imageVersion', field.text);
1018
+ field.optional('runningImageId', field.text);
1019
+ field.optional('runningTaskCount', field.count);
1020
+ field.optional('updatedAt', field.instant);
1021
+ field.optional('error', (keyArg) => field.messageText(keyArg, maximumSparkMessageTextLength));
1022
+ return field.errors;
1023
+ };
1024
+
1025
+ const validateSparkNodeRuntimeInfo = (valueArg: unknown, labelArg: string): string[] => {
1026
+ const reading = readSparkRecord(
1027
+ valueArg,
1028
+ labelArg,
1029
+ [
1030
+ 'runtime',
1031
+ 'nodeId',
1032
+ 'platform',
1033
+ 'arch',
1034
+ 'sparkVersion',
1035
+ 'cloudlyConnectionStatus',
1036
+ 'dockerAvailable',
1037
+ 'checkedAt',
1038
+ ],
1039
+ [
1040
+ 'mode',
1041
+ 'hostname',
1042
+ 'osRelease',
1043
+ 'cloudlyUrl',
1044
+ 'swarmNodeId',
1045
+ 'serveZoneServices',
1046
+ 'lastError',
1047
+ ],
1048
+ );
1049
+ if (reading.record === null) {
1050
+ return reading.errors;
1051
+ }
1052
+ const runtimeInfo = reading.record;
1053
+ const field = createSparkFieldErrors(runtimeInfo, labelArg);
1054
+ if (runtimeInfo.runtime !== 'spark') {
1055
+ field.errors.push(`${labelArg} runtime must be spark`);
1056
+ }
1057
+ field.identifier('nodeId');
1058
+ field.text('platform');
1059
+ field.text('arch');
1060
+ field.text('sparkVersion');
1061
+ field.member('cloudlyConnectionStatus', sparkCloudlyConnectionStatuses);
1062
+ field.boolean('dockerAvailable');
1063
+ field.instant('checkedAt');
1064
+ field.optional('mode', (keyArg) => field.member(keyArg, sparkNodeModes));
1065
+ field.optional('hostname', field.text);
1066
+ field.optional('osRelease', field.text);
1067
+ field.optional('cloudlyUrl', field.text);
1068
+ field.optional('swarmNodeId', field.identifier);
1069
+ field.optional('lastError', (keyArg) => field.messageText(keyArg, maximumSparkMessageTextLength));
1070
+ if (runtimeInfo.serveZoneServices !== undefined) {
1071
+ const services = readDataElements(runtimeInfo.serveZoneServices, maximumSparkRuntimeServices);
1072
+ if (services === null) {
1073
+ field.errors.push(
1074
+ `${labelArg} serveZoneServices must be a bounded list of at most `
1075
+ + `${maximumSparkRuntimeServices} described services`,
1076
+ );
1077
+ } else {
1078
+ services.forEach((serviceArg, indexArg) => {
1079
+ field.errors.push(...validateServeZoneServiceRuntimeInfo(
1080
+ serviceArg,
1081
+ `${labelArg} serveZoneServices[${indexArg}]`,
1082
+ ));
1083
+ });
1084
+ }
1085
+ }
1086
+ return field.errors;
1087
+ };
1088
+
1089
+ /**
1090
+ * Spark -> Cloudly body for `sparkSwarmNodeContracts.heartbeat.endpoint`.
1091
+ *
1092
+ * Stated here beside the shape it judges, so the server that reads the beat and the node that
1093
+ * writes it apply one set of acceptance rules; a reader that kept its own member checks would
1094
+ * decide on its own which beats exist.
1095
+ *
1096
+ * Every Spark route validator answers the same way — one string per refused member, empty for an
1097
+ * admissible body, and the same `(body) => Promise<string[]>` signature, so a server can hold the
1098
+ * four routes in one table.
1099
+ */
1100
+ export const validateSparkSwarmNodeHeartbeatRequest = async (
1101
+ requestArg: unknown,
1102
+ ): Promise<string[]> => {
1103
+ const label = 'Spark Swarm node heartbeat request';
1104
+ try {
1105
+ const envelope = readSparkRouteEnvelope(
1106
+ requestArg,
1107
+ label,
1108
+ ['nodeId', 'nodeToken', 'protocol', 'metrics', 'runtimeInfo'],
1109
+ );
1110
+ if (envelope.record === null) {
1111
+ return envelope.errors;
1112
+ }
1113
+ envelope.errors.push(...validateSparkNodeMetrics(
1114
+ envelope.record.metrics,
1115
+ `${label} metrics`,
1116
+ ));
1117
+ envelope.errors.push(...validateSparkNodeRuntimeInfo(
1118
+ envelope.record.runtimeInfo,
1119
+ `${label} runtimeInfo`,
1120
+ ));
1121
+ return envelope.errors;
1122
+ } catch {
1123
+ return [`${label} must be safely inspectable`];
1124
+ }
1125
+ };
1126
+
1127
+ /** Spark -> Cloudly body for `sparkSwarmNodeContracts.metricsSample.endpoint`. */
1128
+ export const validateSparkMetricsSampleRequest = async (
1129
+ requestArg: unknown,
1130
+ ): Promise<string[]> => {
1131
+ const label = 'Spark metrics sample request';
1132
+ try {
1133
+ const envelope = readSparkRouteEnvelope(
1134
+ requestArg,
1135
+ label,
1136
+ ['nodeId', 'nodeToken', 'protocol', 'sample'],
1137
+ );
1138
+ if (envelope.record === null) {
1139
+ return envelope.errors;
1140
+ }
1141
+ envelope.errors.push(...validateNodeMetricsSample(
1142
+ envelope.record.sample,
1143
+ `${label} sample`,
1144
+ ));
1145
+ return envelope.errors;
1146
+ } catch {
1147
+ return [`${label} must be safely inspectable`];
1148
+ }
1149
+ };
1150
+
1151
+ /**
1152
+ * Spark -> Cloudly body for `sparkSwarmNodeContracts.actionResult.endpoint`.
1153
+ *
1154
+ * `pending` is the one status a node never reports: an action it has not started has no result to
1155
+ * post, and a server that accepted one would reopen an action it already handed out.
1156
+ */
1157
+ export const validateSparkActionResultRequest = async (
1158
+ requestArg: unknown,
1159
+ ): Promise<string[]> => {
1160
+ const label = 'Spark action result request';
1161
+ try {
1162
+ const envelope = readSparkRouteEnvelope(
1163
+ requestArg,
1164
+ label,
1165
+ ['nodeId', 'nodeToken', 'protocol', 'actionId', 'status'],
1166
+ ['resultText'],
1167
+ );
1168
+ if (envelope.record === null) {
1169
+ return envelope.errors;
1170
+ }
1171
+ const field = createSparkFieldErrors(envelope.record, label);
1172
+ field.identifier('actionId');
1173
+ field.member('status', sparkActionResultStatuses);
1174
+ field.optional('resultText', (keyArg) => field.messageText(
1175
+ keyArg,
1176
+ maximumSparkActionResultTextLength,
1177
+ ));
1178
+ envelope.errors.push(...field.errors);
1179
+ return envelope.errors;
1180
+ } catch {
1181
+ return [`${label} must be safely inspectable`];
1182
+ }
1183
+ };
1184
+
738
1185
  /**
739
1186
  * The exact five members of a target that the set digest is taken over.
740
1187
  *
@@ -0,0 +1,97 @@
1
+ import type {
2
+ IAppStoreFilesystemStorageRequest,
3
+ IAppStoreObjectStorageRequest,
4
+ } from '../appstore/types.js';
5
+ import { deepFreezeValue } from '../private/canonicaljson.js';
6
+
7
+ export interface IStorageRequestCanonicalDigestGoldenVector<TInput> {
8
+ input: TInput;
9
+ canonicalJson: string;
10
+ sha256: string;
11
+ }
12
+
13
+ const filesystemRequest: IAppStoreFilesystemStorageRequest = {
14
+ id: 'app-state',
15
+ kind: 'filesystem',
16
+ storageClass: 'databaseFast',
17
+ accessMode: 'ReadWriteOnce',
18
+ reclaimPolicy: 'delete',
19
+ mountPath: '/var/lib/app',
20
+ capacity: {
21
+ request: '20GiB',
22
+ limit: '40GiB',
23
+ },
24
+ protection: {
25
+ backup: 'required',
26
+ snapshots: 'portable',
27
+ consistency: 'applicationConsistent',
28
+ },
29
+ };
30
+
31
+ const objectStorageLauncherEnvironmentRequest: IAppStoreObjectStorageRequest = {
32
+ id: 'object-data',
33
+ kind: 'objectStorage',
34
+ storageClass: 'backupCapacity',
35
+ accessMode: 'readWrite',
36
+ reclaimPolicy: 'retain',
37
+ delivery: {
38
+ type: 'launcher-environment',
39
+ keys: {
40
+ endpoint: 'APP_S3_ENDPOINT',
41
+ bucket: 'APP_S3_BUCKET',
42
+ region: 'APP_S3_REGION',
43
+ accessKeyId: 'APP_S3_ACCESS_KEY',
44
+ secretAccessKey: 'APP_S3_SECRET_KEY',
45
+ },
46
+ },
47
+ capacity: {
48
+ request: '500GiB',
49
+ },
50
+ protection: {
51
+ backup: 'required',
52
+ versioning: 'required',
53
+ retentionDays: 30,
54
+ },
55
+ };
56
+
57
+ const objectStorageFileRequest: IAppStoreObjectStorageRequest = {
58
+ id: 'object-archive',
59
+ kind: 'objectStorage',
60
+ storageClass: 'archiveCapacity',
61
+ accessMode: 'readOnly',
62
+ reclaimPolicy: 'delete',
63
+ delivery: {
64
+ type: 'file',
65
+ targetPath: '/run/secrets/object-archive.json',
66
+ format: 'servezone-object-storage',
67
+ uid: 10_000,
68
+ gid: 10_000,
69
+ mode: 0o400,
70
+ },
71
+ };
72
+
73
+ /**
74
+ * The storage request digest, written down for both kinds and both deliveries.
75
+ *
76
+ * A control plane and its consumer compare this value, so it is stated here as bytes rather than
77
+ * as whatever the current normalizer happens to produce: a member that starts or stops
78
+ * participating, an optional member that starts being carried when it is absent, or a delivery
79
+ * mode written as anything but its number would move these digests and be caught here.
80
+ */
81
+ export const storageRequestCanonicalDigestGoldenVectors = deepFreezeValue({
82
+ filesystem: {
83
+ input: filesystemRequest,
84
+ canonicalJson: '{"accessMode":"ReadWriteOnce","capacity":{"limit":"40GiB","request":"20GiB"},"id":"app-state","kind":"filesystem","mountPath":"/var/lib/app","protection":{"backup":"required","consistency":"applicationConsistent","snapshots":"portable"},"reclaimPolicy":"delete","storageClass":"databaseFast"}',
85
+ sha256: '0f8dc2e480ade7110be1f1d95f666cc56d5b90110d1c4a1b8b8383e2eeda6fae',
86
+ },
87
+ objectStorageLauncherEnvironment: {
88
+ input: objectStorageLauncherEnvironmentRequest,
89
+ canonicalJson: '{"accessMode":"readWrite","capacity":{"request":"500GiB"},"delivery":{"keys":{"accessKeyId":"APP_S3_ACCESS_KEY","bucket":"APP_S3_BUCKET","endpoint":"APP_S3_ENDPOINT","region":"APP_S3_REGION","secretAccessKey":"APP_S3_SECRET_KEY"},"type":"launcher-environment"},"id":"object-data","kind":"objectStorage","protection":{"backup":"required","retentionDays":30,"versioning":"required"},"reclaimPolicy":"retain","storageClass":"backupCapacity"}',
90
+ sha256: '88bd943800bea75b576612b966484e66ddbf7ec9529a7f29dd1eadc9a36e2b79',
91
+ },
92
+ objectStorageFile: {
93
+ input: objectStorageFileRequest,
94
+ canonicalJson: '{"accessMode":"readOnly","delivery":{"format":"servezone-object-storage","gid":10000,"mode":256,"targetPath":"/run/secrets/object-archive.json","type":"file","uid":10000},"id":"object-archive","kind":"objectStorage","reclaimPolicy":"delete","storageClass":"archiveCapacity"}',
95
+ sha256: '13186abbfe733b3917363e0694d367f2d6aa87a4064445532d7c857ee274f795',
96
+ },
97
+ });
@@ -1,4 +1,10 @@
1
+ import type { TAppStoreStorageRequest } from '../appstore/types.js';
1
2
  import type { TSecretManagementScope } from '../data/secret.js';
3
+ import {
4
+ canonicalizeStrictJson,
5
+ createCanonicalJsonSha256Hex,
6
+ } from '../private/canonicaljson.js';
7
+ import { createStorageContractReaders } from '../private/storagecontract.js';
2
8
 
3
9
  /**
4
10
  * Backend-neutral storage vocabulary shared by App Store manifests, control
@@ -186,8 +192,12 @@ export interface IResolvedStorageBindingBase {
186
192
  /** Stable App Store storageRequests[].id. */
187
193
  requestId: string;
188
194
  /**
189
- * Canonical digest of the normalized request. Adapters use it to distinguish
190
- * an idempotent reconciliation from a migration.
195
+ * Canonical digest of the normalized request, exactly as
196
+ * `createStorageRequestSha256` states it: bare lowercase hex over the request
197
+ * alone. Adapters use it to distinguish an idempotent reconciliation from a
198
+ * migration, and a consumer re-derives it to recognise the request it asked
199
+ * for, so the control plane and the consumer call that one function rather
200
+ * than each deriving a value the other cannot reproduce.
191
201
  */
192
202
  requestDigest: string;
193
203
  kind: TStorageResourceKind;
@@ -232,3 +242,72 @@ extends IResolvedStorageBindingBase {
232
242
  export type TResolvedStorageBinding =
233
243
  | IResolvedFilesystemStorageBinding
234
244
  | IResolvedObjectStorageBinding;
245
+
246
+ export class StorageContractError extends Error {
247
+ public constructor(reasonArg: string) {
248
+ super(`storage contract error: ${reasonArg}`);
249
+ this.name = 'StorageContractError';
250
+ }
251
+ }
252
+
253
+ const fail = (reasonArg: string): never => {
254
+ throw new StorageContractError(reasonArg);
255
+ };
256
+
257
+ const { normalizeStorageRequest: readStorageRequest } =
258
+ createStorageContractReaders(fail);
259
+
260
+ /**
261
+ * The one judgement of what a storage request is: the request read against its exact schema and
262
+ * returned in its contract shape — required members present, optional members carried only when
263
+ * the request declares them, and every value within the bounds an adapter can fulfil.
264
+ *
265
+ * A control plane that admits requests, a fulfillment adapter that provisions them, and an App
266
+ * Store front end that assembles them all have to agree on the same acceptance rules; a second
267
+ * copy of them anywhere is a second answer to which requests exist, and the digest one side issues
268
+ * would name a request the other never accepted. `canonicalizeStorageRequest` and
269
+ * `createStorageRequestSha256` are derived from this function rather than the other way round, so
270
+ * a consumer that needs the judged request itself does not have to re-parse its own canonical
271
+ * bytes.
272
+ *
273
+ * The returned request is a fresh value; the input is never read again after normalization and
274
+ * never mutated. Anything that is not a storage request is refused with `StorageContractError`,
275
+ * whose message names the failing field so a consumer can map it to its own status vocabulary.
276
+ */
277
+ export const normalizeStorageRequest = (
278
+ valueArg: unknown,
279
+ ): TAppStoreStorageRequest => {
280
+ return readStorageRequest(valueArg, 'storage request');
281
+ };
282
+
283
+ /**
284
+ * The exact bytes a storage request's digest is taken over: the request normalized to its contract
285
+ * shape, written as strict canonical JSON — keys in UTF-16 code-unit order, no whitespace, no
286
+ * absent member carried as `null`.
287
+ *
288
+ * Anything that is not a storage request is refused rather than hashed, because a digest of an
289
+ * unjudged body would name a request no adapter could fulfil.
290
+ */
291
+ export const canonicalizeStorageRequest = (valueArg: unknown): string => {
292
+ return canonicalizeStrictJson(normalizeStorageRequest(valueArg), fail);
293
+ };
294
+
295
+ /**
296
+ * The storage request digest: bare lowercase hex SHA-256 over the canonical request, with no
297
+ * prefix and nothing but the request in the hashed value.
298
+ *
299
+ * A control plane issues this value on a binding and a consumer re-derives it from the request it
300
+ * declared; the two only agree because they call this one function. A second derivation elsewhere
301
+ * — a prefix, or a wrapper object around the request — is a different value for the same binding,
302
+ * and the consumer would keep rejecting a binding that is in fact its own.
303
+ */
304
+ export const createStorageRequestSha256 = async (
305
+ valueArg: unknown,
306
+ ): Promise<string> => {
307
+ return createCanonicalJsonSha256Hex(canonicalizeStorageRequest(valueArg), fail);
308
+ };
309
+
310
+ export {
311
+ storageRequestCanonicalDigestGoldenVectors,
312
+ type IStorageRequestCanonicalDigestGoldenVector,
313
+ } from './storage.golden.js';