@serve.zone/interfaces 32.9.0 → 32.10.1

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.
@@ -23,6 +23,8 @@ import { isClusterNodeId } from '../private/nodeidentifier.js';
23
23
  export interface IRuntimeSessionBinding {
24
24
  reporterSessionId: string;
25
25
  nodeId: string;
26
+ /** Cluster read from the controller's owned node record when this session is admitted. */
27
+ clusterId: string;
26
28
  credentialGeneration: number;
27
29
  sessionEpoch: number;
28
30
  controllerInstanceId: string;
@@ -254,12 +256,13 @@ const reference = (valueArg: unknown): IRuntimeAssignmentReference => {
254
256
  return { assignmentId: identifier(value.assignmentId), generation: positive(value.generation), digest: value.digest };
255
257
  };
256
258
  const session = (valueArg: unknown): IRuntimeSessionBinding => {
257
- const value = record(valueArg, ['reporterSessionId', 'nodeId', 'credentialGeneration',
259
+ const value = record(valueArg, ['reporterSessionId', 'nodeId', 'clusterId', 'credentialGeneration',
258
260
  'sessionEpoch', 'controllerInstanceId', 'controller', 'runtimeNamespace']);
259
261
  const controller = record(value.controller, ['kind', 'id', 'epoch']);
260
262
  requireValue(controller.kind === 'cloudly' || controller.kind === 'onebox');
261
263
  return {
262
264
  reporterSessionId: identifier(value.reporterSessionId), nodeId: nodeId(value.nodeId),
265
+ clusterId: identifier(value.clusterId),
263
266
  credentialGeneration: positive(value.credentialGeneration), sessionEpoch: positive(value.sessionEpoch),
264
267
  controllerInstanceId: identifier(value.controllerInstanceId),
265
268
  controller: { kind: controller.kind, id: identifier(controller.id), epoch: identifier(controller.epoch) },
@@ -269,7 +272,8 @@ const session = (valueArg: unknown): IRuntimeSessionBinding => {
269
272
  const sameSession = (leftArg: IRuntimeSessionBinding, rightArg: IRuntimeSessionBinding): boolean =>
270
273
  canonical(leftArg) === canonical(rightArg);
271
274
  const sameOwner = (leftArg: IRuntimeSessionBinding, rightArg: IRuntimeSessionBinding): boolean =>
272
- leftArg.nodeId === rightArg.nodeId && leftArg.runtimeNamespace === rightArg.runtimeNamespace
275
+ leftArg.nodeId === rightArg.nodeId && leftArg.clusterId === rightArg.clusterId
276
+ && leftArg.runtimeNamespace === rightArg.runtimeNamespace
273
277
  && canonical(leftArg.controller) === canonical(rightArg.controller);
274
278
  const matchesAssignment = (sessionArg: IRuntimeSessionBinding, assignmentArg: IRuntimeAssignment): boolean =>
275
279
  sessionArg.nodeId === assignmentArg.nodeId && sessionArg.runtimeNamespace === assignmentArg.runtimeNamespace
@@ -0,0 +1,162 @@
1
+ import type { TSha256Digest } from './immutableimage.js';
2
+ import type { IRuntimeAssignment, IRuntimeAuthorityReference, IRuntimeController } from './runtimeassignment.js';
3
+ import { snapshotRuntimeAssignment, validateRuntimeAssignment } from './runtimeassignment.js';
4
+ import { normalizeServiceAbsolutePath } from './service.js';
5
+ import type { IResolvedFilesystemStorageBinding } from '../platform/storage.js';
6
+ import { canonicalizeStrictJson, createCanonicalJsonSha256Hex } from '../private/canonicaljson.js';
7
+ import { isClusterNodeId } from '../private/nodeidentifier.js';
8
+
9
+ /** Only immutable, portable fields of a ready Corestore filesystem binding. */
10
+ export interface IRuntimeStorageClaimBinding extends Pick<IResolvedFilesystemStorageBinding,
11
+ 'id' | 'serviceId' | 'requestId' | 'requestDigest' | 'generation' | 'observedGeneration'
12
+ | 'policy' | 'resourceRef' | 'mountPath'> {
13
+ kind: 'filesystem';
14
+ status: 'ready';
15
+ accessMode: 'ReadWriteOnce';
16
+ capabilities: { durability: 'persistent'; topology: 'singleNode' };
17
+ }
18
+
19
+ /** Cloudly authority for one retained, node-local volume; no host path or attachment attempt. */
20
+ export interface IRuntimeStorageClaim {
21
+ id: string;
22
+ generation: number;
23
+ previous: IRuntimeAuthorityReference | null;
24
+ controller: IRuntimeController & { kind: 'cloudly' };
25
+ organizationId: string;
26
+ clusterId: string;
27
+ nodeId: string;
28
+ runtimeNamespace: string;
29
+ serviceId: string;
30
+ binding: IRuntimeStorageClaimBinding;
31
+ reclaimPolicy: 'retain';
32
+ digest: TSha256Digest;
33
+ }
34
+
35
+ export const runtimeStorageClaimContract = Object.freeze({
36
+ digestDomain: 'serve.zone/runtime-storage-claim',
37
+ accessMode: 'ReadWriteOnce',
38
+ reclaimPolicy: 'retain',
39
+ } as const);
40
+
41
+ const fail = (): never => { throw new Error('Runtime storage claim contract rejected.'); };
42
+ function requireValue(conditionArg: unknown): asserts conditionArg { if (!conditionArg) fail(); }
43
+ const record = (valueArg: unknown, keysArg: readonly string[]): Record<string, unknown> => {
44
+ requireValue(valueArg !== null && typeof valueArg === 'object' && !Array.isArray(valueArg));
45
+ const prototype = Object.getPrototypeOf(valueArg);
46
+ requireValue(prototype === Object.prototype || prototype === null);
47
+ const keys = Reflect.ownKeys(valueArg);
48
+ requireValue(keys.length === keysArg.length && keysArg.every((keyArg) => keys.includes(keyArg)));
49
+ const result: Record<string, unknown> = Object.create(null);
50
+ for (const key of keysArg) {
51
+ const descriptor = Object.getOwnPropertyDescriptor(valueArg, key);
52
+ requireValue(descriptor?.enumerable && Object.hasOwn(descriptor, 'value'));
53
+ result[key] = descriptor.value;
54
+ }
55
+ return result;
56
+ };
57
+ const id = (valueArg: unknown): string => {
58
+ requireValue(typeof valueArg === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._@-]{0,191}$/.test(valueArg));
59
+ return valueArg as string;
60
+ };
61
+ const assignmentId = (valueArg: unknown): string => {
62
+ requireValue(typeof valueArg === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(valueArg));
63
+ return valueArg as string;
64
+ };
65
+ const generation = (valueArg: unknown): number => {
66
+ requireValue(typeof valueArg === 'number' && Number.isSafeInteger(valueArg)
67
+ && valueArg > 0 && valueArg < Number.MAX_SAFE_INTEGER);
68
+ return valueArg as number;
69
+ };
70
+ const digest = (valueArg: unknown): TSha256Digest => {
71
+ requireValue(typeof valueArg === 'string' && /^sha256:[a-f0-9]{64}$/.test(valueArg));
72
+ return valueArg as TSha256Digest;
73
+ };
74
+ const reference = (valueArg: unknown): IRuntimeAuthorityReference => {
75
+ const value = record(valueArg, ['id', 'generation', 'digest']);
76
+ return { id: assignmentId(value.id), generation: generation(value.generation), digest: digest(value.digest) };
77
+ };
78
+ const controller = (valueArg: unknown): IRuntimeStorageClaim['controller'] => {
79
+ const value = record(valueArg, ['kind', 'id', 'epoch']);
80
+ requireValue(value.kind === 'cloudly');
81
+ return { kind: 'cloudly', id: assignmentId(value.id), epoch: assignmentId(value.epoch) };
82
+ };
83
+ const mountPath = (valueArg: unknown): string => {
84
+ requireValue(typeof valueArg === 'string' && normalizeServiceAbsolutePath(valueArg) === valueArg
85
+ && valueArg !== '/' && valueArg !== '/run' && valueArg !== '/run/secrets'
86
+ && !valueArg.startsWith('/run/secrets/') && !'/run/secrets'.startsWith(`${valueArg}/`));
87
+ return valueArg as string;
88
+ };
89
+ const binding = (valueArg: unknown): IRuntimeStorageClaimBinding => {
90
+ const value = record(valueArg, ['id', 'serviceId', 'requestId', 'requestDigest', 'kind',
91
+ 'generation', 'observedGeneration', 'status', 'policy', 'resourceRef', 'mountPath',
92
+ 'accessMode', 'capabilities']);
93
+ requireValue(value.kind === 'filesystem' && value.status === 'ready'
94
+ && value.accessMode === 'ReadWriteOnce'
95
+ && typeof value.requestDigest === 'string' && /^[a-f0-9]{64}$/.test(value.requestDigest));
96
+ const policy = record(value.policy, ['classId', 'revision']);
97
+ const capabilities = record(value.capabilities, ['durability', 'topology']);
98
+ requireValue(capabilities.durability === 'persistent' && capabilities.topology === 'singleNode');
99
+ const bindingGeneration = generation(value.generation);
100
+ requireValue(generation(value.observedGeneration) === bindingGeneration);
101
+ return { id: id(value.id), serviceId: assignmentId(value.serviceId), requestId: id(value.requestId),
102
+ requestDigest: value.requestDigest as string, kind: 'filesystem', generation: bindingGeneration,
103
+ observedGeneration: bindingGeneration, status: 'ready',
104
+ policy: { classId: id(policy.classId), revision: id(policy.revision) },
105
+ resourceRef: id(value.resourceRef), mountPath: mountPath(value.mountPath),
106
+ accessMode: 'ReadWriteOnce', capabilities: { durability: 'persistent', topology: 'singleNode' } };
107
+ };
108
+
109
+ /** Detached exact-schema snapshot. Digest and authoritative provider readiness are separate checks. */
110
+ export const snapshotRuntimeStorageClaim = (valueArg: unknown): IRuntimeStorageClaim => {
111
+ const value = record(valueArg, ['id', 'generation', 'previous', 'controller', 'organizationId',
112
+ 'clusterId', 'nodeId', 'runtimeNamespace', 'serviceId', 'binding', 'reclaimPolicy', 'digest']);
113
+ const claimId = assignmentId(value.id), claimGeneration = generation(value.generation);
114
+ const previous = value.previous === null ? null : reference(value.previous);
115
+ requireValue(claimGeneration === 1 ? previous === null
116
+ : previous?.id === claimId && previous.generation === claimGeneration - 1);
117
+ requireValue(isClusterNodeId(value.nodeId) && value.reclaimPolicy === 'retain');
118
+ const claimBinding = binding(value.binding), serviceId = assignmentId(value.serviceId);
119
+ requireValue(claimBinding.serviceId === serviceId);
120
+ return { id: claimId, generation: claimGeneration, previous, controller: controller(value.controller),
121
+ organizationId: assignmentId(value.organizationId), clusterId: id(value.clusterId),
122
+ nodeId: value.nodeId as string, runtimeNamespace: assignmentId(value.runtimeNamespace), serviceId,
123
+ binding: claimBinding, reclaimPolicy: 'retain', digest: digest(value.digest) };
124
+ };
125
+
126
+ export const computeRuntimeStorageClaimDigest = async (valueArg: unknown): Promise<TSha256Digest> => {
127
+ const { digest: _digest, ...payload } = snapshotRuntimeStorageClaim(valueArg);
128
+ return `sha256:${await createCanonicalJsonSha256Hex(canonicalizeStrictJson({
129
+ domain: runtimeStorageClaimContract.digestDomain, payload,
130
+ }, fail), fail)}`;
131
+ };
132
+
133
+ export const validateRuntimeStorageClaim = async (valueArg: unknown): Promise<string[]> => {
134
+ try {
135
+ const claim = snapshotRuntimeStorageClaim(valueArg);
136
+ return claim.digest === await computeRuntimeStorageClaimDigest(claim)
137
+ ? [] : ['runtime storage claim digest does not match'];
138
+ } catch {
139
+ return ['runtime storage claim must use its exact canonical schema'];
140
+ }
141
+ };
142
+
143
+ /** Content binding only; controller authentication, provider checks and attachment fencing remain owner-owned. */
144
+ export const bindRuntimeStorageClaimToAssignment = async (
145
+ claimArg: unknown, assignmentArg: unknown, expectedClusterIdArg: unknown,
146
+ ): Promise<{ claim: IRuntimeStorageClaim; assignment: IRuntimeAssignment }> => {
147
+ const claim = snapshotRuntimeStorageClaim(claimArg);
148
+ const assignment = snapshotRuntimeAssignment(assignmentArg);
149
+ if ((await validateRuntimeStorageClaim(claim)).length || (await validateRuntimeAssignment(assignment)).length) fail();
150
+ const expectedClusterId = id(expectedClusterIdArg);
151
+ const reference = assignment.workload.storage.find((itemArg) => itemArg.id === claim.id);
152
+ requireValue(reference?.generation === claim.generation && reference.digest === claim.digest
153
+ && claim.clusterId === expectedClusterId
154
+ && assignment.controller.kind === 'cloudly'
155
+ && assignment.controller.id === claim.controller.id
156
+ && assignment.controller.epoch === claim.controller.epoch
157
+ && assignment.organizationId === claim.organizationId
158
+ && assignment.nodeId === claim.nodeId
159
+ && assignment.runtimeNamespace === claim.runtimeNamespace
160
+ && assignment.serviceId === claim.serviceId);
161
+ return { claim, assignment };
162
+ };
package/ts/data/secret.ts CHANGED
@@ -84,15 +84,27 @@ export type TSecretVersionRetentionReferenceKind =
84
84
  | 'applied-manifest'
85
85
  | 'previous-accepted-manifest'
86
86
  | 'rollback-manifest'
87
+ | 'runtime-assignment'
87
88
  | 'backup'
88
89
  | 'retention-window';
89
90
 
90
- export interface ISecretVersionRetentionReference {
91
- kind: TSecretVersionRetentionReferenceKind;
91
+ export interface IRuntimeAssignmentSecretVersionRetentionReference {
92
+ kind: 'runtime-assignment';
93
+ resourceId: string;
94
+ expiresAt?: never;
95
+ }
96
+
97
+ export interface IExpiringSecretVersionRetentionReference {
98
+ kind: Exclude<TSecretVersionRetentionReferenceKind, 'runtime-assignment'>;
92
99
  resourceId: string;
93
100
  expiresAt?: number;
94
101
  }
95
102
 
103
+ /** Retains the existing public name while discriminating indefinite assignment holds. */
104
+ export type ISecretVersionRetentionReference =
105
+ | IRuntimeAssignmentSecretVersionRetentionReference
106
+ | IExpiringSecretVersionRetentionReference;
107
+
96
108
  export const secretVersionRetentionReferenceLimits = Object.freeze({
97
109
  pageSizeMaximum: 256,
98
110
  });
@@ -435,6 +447,7 @@ export const validateSecretVersionRetentionReferences = (
435
447
  'applied-manifest',
436
448
  'previous-accepted-manifest',
437
449
  'rollback-manifest',
450
+ 'runtime-assignment',
438
451
  'backup',
439
452
  'retention-window',
440
453
  ]);
@@ -444,6 +457,7 @@ export const validateSecretVersionRetentionReferences = (
444
457
  || !hasOnlyKeys(referenceArg, ['kind', 'resourceId', 'expiresAt'])
445
458
  || !kinds.has(referenceArg.kind as TSecretVersionRetentionReferenceKind)
446
459
  || !isSecretIdentifier(referenceArg.resourceId)
460
+ || (referenceArg.kind === 'runtime-assignment' && Object.hasOwn(referenceArg, 'expiresAt'))
447
461
  || (referenceArg.expiresAt !== undefined
448
462
  && !isNonNegativeSafeInteger(referenceArg.expiresAt))) {
449
463
  errors.push(`secret version retention references[${index}] must be canonical`);
@@ -501,28 +501,30 @@ export const bindGetRuntimeAssignmentSecretMaterialRequest = async (
501
501
 
502
502
  /**
503
503
  * Verify one complete response without opening a private key. The caller must
504
- * supply its authenticated current session, current assignment, pinned config,
505
- * enrolled cluster and active recipient from their respective trusted owners.
504
+ * supply its authenticated current session, current assignment, pinned config
505
+ * and active recipient from their respective trusted owners. The controller-issued
506
+ * session binding supplies the enrolled cluster.
506
507
  */
507
508
  export const bindGetRuntimeAssignmentSecretMaterialResponse = async (
508
509
  valueArg: unknown, requestArg: unknown, assignmentArg: unknown, configArg: unknown,
509
- trustedSessionArg: unknown, trustedClusterIdArg: string, activeRecipientArg: unknown,
510
+ trustedSessionArg: unknown, activeRecipientArg: unknown,
510
511
  ): Promise<IGetRuntimeAssignmentSecretMaterialResponse> => {
511
512
  try {
512
513
  const value = exact(capture(valueArg, runtimeAssignmentSecretContract.maximumMaterialBytes), ['material']);
514
+ const trustedSession = session(capture(trustedSessionArg));
513
515
  const material = exact(value.material, [
514
516
  'assignment', 'nodeId', 'secrets', 'recipientKeyId', 'recipientGeneration',
515
517
  'authority', 'manifest', 'workloadInit', 'entries',
516
518
  ]);
517
519
  const request = await bindGetRuntimeAssignmentSecretMaterialRequest(
518
- requestArg, assignmentArg, trustedSessionArg, activeRecipientArg,
520
+ requestArg, assignmentArg, trustedSession, activeRecipientArg,
519
521
  );
520
522
  const assignment = snapshotRuntimeAssignment(assignmentArg);
521
523
  const authority = await bindRuntimeAssignmentSecretAuthority(
522
- material.authority, material.manifest, assignment, configArg, trustedClusterIdArg,
524
+ material.authority, material.manifest, assignment, configArg, trustedSession.clusterId,
523
525
  );
524
526
  const manifest = material.manifest as IResolvedSecretManifest;
525
- requireValue(identifier(trustedClusterIdArg) === manifest.clusterId
527
+ requireValue(trustedSession.clusterId === manifest.clusterId
526
528
  && material.nodeId === assignment.nodeId
527
529
  && same(assignmentReference(material.assignment), request.assignment)
528
530
  && same(authorityReference(material.secrets), request.secrets)