@serve.zone/interfaces 32.55.1 → 32.57.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.
@@ -0,0 +1,127 @@
1
+ import { readExactDataRecord } from '../private/dataproperties.js';
2
+ /**
3
+ * The refusal a controller states when it does not take a node's runtime session request.
4
+ *
5
+ * It travels as `TypedResponseError.errorData` on `registerPalletRuntimeSession` and on every
6
+ * session-scoped read and report a node sends its controller. `code` is a frozen wire value.
7
+ * `momentaryReasons` are the controller's own momentary state, not a judgement of the node: a
8
+ * registering node waits and offers the same registration again, keeping everything it runs.
9
+ * Every other reason, and an error that carries no such payload, is a verdict — on a registration
10
+ * it ends the node's session. Momentary against verdict governs only whether a node defers a
11
+ * registration: a node retries its session reads and reports regardless of the reason, so the
12
+ * reasons a report is refused with (`state-conflict`, `*-write-conflict`, a capacity limit such as
13
+ * `log-intake-saturated`) are unchanged by this contract.
14
+ *
15
+ * Who states which reason today, on a registration (Cloudly ≥33.19.1, `isBudgetRefusal`):
16
+ * - Cloudly states `controller-deadline` when the request ran out of
17
+ * `runtimeSessionRequestBounds.controllerBudgetMs`, and `controller-unavailable` when it is
18
+ * stopping. Every other failure, a transient one of its own included, is answered without a
19
+ * payload and so reads as a verdict.
20
+ * - No relay states a refusal of its own yet.
21
+ *
22
+ * The rule producers adopt:
23
+ * - Cloudly states `controller-unavailable` on a registration for every transient failure of its
24
+ * own: its database connection lost or unavailable, a registration write conflict, a race with a
25
+ * credential being applied.
26
+ * - A relay states only `controller-unavailable`, and only when it cannot carry the request or its
27
+ * answer: no Cloudly link, or its own bound (`runtimeSessionRequestBounds`) ran out. Every
28
+ * answer Cloudly gave it forwards untouched; it never states a verdict or `controller-deadline`.
29
+ * - A verdict names the check that refused: a revoked or inactive credential, a deleted, retiring
30
+ * or foreign node, an invalid body. It never carries a momentary reason.
31
+ *
32
+ * The payload is exactly `{ code, reason }`. Pallet 36.3.14 reads the two members and tolerates
33
+ * others, but every reader built on `readRuntimeSessionRefusal` refuses a payload with any other
34
+ * member, and a refused payload reads as no refusal — a momentary refusal would turn into a
35
+ * verdict. A producer therefore never adds a member without gating it on the peer's spoken
36
+ * interfaces version.
37
+ */
38
+ export const runtimeSessionRefusalContract = Object.freeze({
39
+ code: 'runtime-session-report-refused',
40
+ momentaryReasons: Object.freeze(['controller-deadline', 'controller-unavailable']),
41
+ /**
42
+ * A reason is one or more words of lowercase ASCII letters joined by single hyphens, at most
43
+ * this many characters. It names a check, never a value: no digit, case, dot, colon or other
44
+ * separator can carry an identifier, address, digest, count or credential across.
45
+ */
46
+ maximumReasonLength: 64,
47
+ });
48
+ const isRefusalReason = (valueArg) => (typeof valueArg === 'string'
49
+ && valueArg.length <= runtimeSessionRefusalContract.maximumReasonLength
50
+ && /^[a-z]+(?:-[a-z]+)*$/.test(valueArg));
51
+ const reasonRule = 'must be lowercase letter words joined by single hyphens, at most '
52
+ + `${runtimeSessionRefusalContract.maximumReasonLength} characters`;
53
+ /** Validates one runtime session refusal and names every reason it is refused. */
54
+ export const validateRuntimeSessionRefusal = (valueArg, pathArg = 'runtime session refusal') => {
55
+ const refusal = readExactDataRecord(valueArg, ['code', 'reason']);
56
+ if (refusal === null) {
57
+ return [`${pathArg} must use its exact schema`];
58
+ }
59
+ const errors = [];
60
+ if (refusal.code !== runtimeSessionRefusalContract.code) {
61
+ errors.push(`${pathArg} code must be ${runtimeSessionRefusalContract.code}`);
62
+ }
63
+ if (!isRefusalReason(refusal.reason)) {
64
+ errors.push(`${pathArg} reason ${reasonRule}`);
65
+ }
66
+ return errors;
67
+ };
68
+ /** The payload a controller or relay answers a refused request with; throws on a non-word reason. */
69
+ export const buildRuntimeSessionRefusal = (reasonArg) => {
70
+ if (!isRefusalReason(reasonArg)) {
71
+ throw new Error(`runtime session refusal reason ${reasonRule}`);
72
+ }
73
+ return Object.freeze({ code: runtimeSessionRefusalContract.code, reason: reasonArg });
74
+ };
75
+ /**
76
+ * The refusal inside an error payload, or `null` when the value is not exactly one.
77
+ *
78
+ * It reads a value that survived serialization, the way `protocol.readProtocolRefusal` does: own
79
+ * data properties only, exactly `code` and `reason`, nothing that runs.
80
+ */
81
+ export const readRuntimeSessionRefusal = (valueArg) => {
82
+ const refusal = readExactDataRecord(valueArg, ['code', 'reason']);
83
+ if (refusal === null || validateRuntimeSessionRefusal(refusal).length > 0) {
84
+ return null;
85
+ }
86
+ return Object.freeze({ code: runtimeSessionRefusalContract.code, reason: refusal.reason });
87
+ };
88
+ /**
89
+ * The momentary reason an error payload states, or `null` when it is a verdict: any other reason,
90
+ * a payload that is not exactly a refusal, or no payload at all.
91
+ */
92
+ export const readMomentaryRuntimeSessionRefusal = (valueArg) => {
93
+ const refusal = readRuntimeSessionRefusal(valueArg);
94
+ if (refusal === null) {
95
+ return null;
96
+ }
97
+ const momentary = runtimeSessionRefusalContract.momentaryReasons
98
+ .find((reasonArg) => reasonArg === refusal.reason);
99
+ return momentary ?? null;
100
+ };
101
+ /**
102
+ * How long each hop may hold a node's runtime session request, so the hop nearest the decision
103
+ * answers first and the node always reads a stated refusal rather than its own timeout.
104
+ */
105
+ export const runtimeSessionRequestBounds = Object.freeze({
106
+ /**
107
+ * Cloudly's budget for every node request, registration included. When it runs out Cloudly
108
+ * answers `controller-deadline`.
109
+ */
110
+ controllerBudgetMs: 6_000,
111
+ /**
112
+ * A relay's bound on a registration, from its arrival at the relay to the answer, including any
113
+ * wait for the relay's own Cloudly link. It exceeds `controllerBudgetMs` by more than a second so
114
+ * Cloudly's own `controller-deadline` reaches the node through the relay, and stays below
115
+ * TypedSocket's 10 s connection-restore deadline (`TYPEDSOCKET_MAX_CONNECTION_RESTORE_TIMEOUT_MS`,
116
+ * which a client can only lower) so the node reads an answer before its restore hook times out.
117
+ * When it runs out the relay answers `controller-unavailable`.
118
+ */
119
+ relayRegistrationBudgetMs: 9_000,
120
+ /**
121
+ * A relay's bound on forwarding a session read or report over its established Cloudly link.
122
+ * It exceeds `controllerBudgetMs` so Cloudly's answer arrives first; when it runs out the relay
123
+ * answers `controller-unavailable`.
124
+ */
125
+ relaySessionForwardMs: 8_000,
126
+ });
127
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicnVudGltZXNlc3Npb25yZWZ1c2FsLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vdHMvZGF0YS9ydW50aW1lc2Vzc2lvbnJlZnVzYWwudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLG1CQUFtQixFQUFFLE1BQU0sOEJBQThCLENBQUM7QUFFbkU7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7O0dBbUNHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sNkJBQTZCLEdBQUcsTUFBTSxDQUFDLE1BQU0sQ0FBQztJQUN6RCxJQUFJLEVBQUUsZ0NBQWdDO0lBQ3RDLGdCQUFnQixFQUFFLE1BQU0sQ0FBQyxNQUFNLENBQUMsQ0FBQyxxQkFBcUIsRUFBRSx3QkFBd0IsQ0FBVSxDQUFDO0lBQzNGOzs7O09BSUc7SUFDSCxtQkFBbUIsRUFBRSxFQUFFO0NBQ2YsQ0FBQyxDQUFDO0FBY1osTUFBTSxlQUFlLEdBQUcsQ0FBQyxRQUFpQixFQUFzQixFQUFFLENBQUMsQ0FDakUsT0FBTyxRQUFRLEtBQUssUUFBUTtPQUN6QixRQUFRLENBQUMsTUFBTSxJQUFJLDZCQUE2QixDQUFDLG1CQUFtQjtPQUNwRSxzQkFBc0IsQ0FBQyxJQUFJLENBQUMsUUFBUSxDQUFDLENBQ3pDLENBQUM7QUFFRixNQUFNLFVBQVUsR0FBRyxtRUFBbUU7TUFDbEYsR0FBRyw2QkFBNkIsQ0FBQyxtQkFBbUIsYUFBYSxDQUFDO0FBRXRFLGtGQUFrRjtBQUNsRixNQUFNLENBQUMsTUFBTSw2QkFBNkIsR0FBRyxDQUMzQyxRQUFpQixFQUNqQixPQUFPLEdBQUcseUJBQXlCLEVBQ3pCLEVBQUU7SUFDWixNQUFNLE9BQU8sR0FBRyxtQkFBbUIsQ0FBQyxRQUFRLEVBQUUsQ0FBQyxNQUFNLEVBQUUsUUFBUSxDQUFDLENBQUMsQ0FBQztJQUNsRSxJQUFJLE9BQU8sS0FBSyxJQUFJLEVBQUUsQ0FBQztRQUNyQixPQUFPLENBQUMsR0FBRyxPQUFPLDRCQUE0QixD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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serve.zone/interfaces",
3
- "version": "32.55.1",
3
+ "version": "32.57.0",
4
4
  "private": false,
5
5
  "description": "Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.",
6
6
  "exports": {
package/readme.md CHANGED
@@ -1452,6 +1452,64 @@ carries the same offer, judged in the same order before the JWT, and answers wit
1452
1452
  the server's own offer. Both registration methods reject HTTP transport. Sensitive requests and registry
1453
1453
  responses must be excluded from hooks, logs, diagnostics and durable journals.
1454
1454
 
1455
+ ### Runtime Session Refusals
1456
+
1457
+ A controller that does not take a node's registration, session read or report
1458
+ answers `TypedResponseError` with `data.IRuntimeSessionRefusal` as its
1459
+ `errorData`: exactly `{ code: 'runtime-session-report-refused', reason }`
1460
+ (`data.runtimeSessionRefusalContract.code`, a frozen wire value). The reason is a
1461
+ word naming the check that refused, never a value: lowercase ASCII letter words
1462
+ joined by single hyphens, at most 64 characters
1463
+ (`runtimeSessionRefusalContract.maximumReasonLength`), so no identifier, address,
1464
+ digest, count or credential can cross in it.
1465
+
1466
+ Two reasons are the controller's own momentary state
1467
+ (`runtimeSessionRefusalContract.momentaryReasons`): a registering node waits and
1468
+ offers the same registration again, keeping what it runs. Every other reason,
1469
+ and an error without this payload, is a verdict; on a registration it ends the
1470
+ node's session. Momentary against verdict governs only whether a node defers a
1471
+ registration: a node retries its session reads and reports regardless of the
1472
+ reason, so the reasons a report is refused with (`state-conflict`,
1473
+ `*-write-conflict`, a capacity limit such as `log-intake-saturated`) are
1474
+ unchanged by this contract.
1475
+
1476
+ On a registration today (Cloudly ≥33.19.1), Cloudly attaches the payload only
1477
+ when the refusal is no judgement of the node: its budget ran out
1478
+ (`controller-deadline`) or it is stopping (`controller-unavailable`). Every
1479
+ other failure, a transient one of its own included, is answered without a
1480
+ payload and so reads as a verdict. No relay states a refusal of its own yet.
1481
+
1482
+ The rule producers adopt:
1483
+
1484
+ | Reason | Stated by | When |
1485
+ | --- | --- | --- |
1486
+ | `controller-deadline` | Cloudly | the request ran out of `controllerBudgetMs` (stated today) |
1487
+ | `controller-unavailable` | Cloudly | it is stopping (stated today); any other transient failure of its own on a registration: database connection lost or unavailable, a registration write conflict, a race with a credential being applied |
1488
+ | `controller-unavailable` | relay | only when it cannot carry the request or its answer: no Cloudly link, or its own bound ran out |
1489
+ | any other word | Cloudly | a verdict: revoked or inactive credential, deleted, retiring or foreign node, invalid body |
1490
+
1491
+ A relay forwards every answer Cloudly gave untouched and states nothing but
1492
+ `controller-unavailable` itself. `data.buildRuntimeSessionRefusal(reason)` builds
1493
+ the payload and throws on a reason that is not a word;
1494
+ `data.validateRuntimeSessionRefusal` names every violation;
1495
+ `data.readRuntimeSessionRefusal(errorData)` answers the refusal or `null`, and
1496
+ `data.readMomentaryRuntimeSessionRefusal(errorData)` answers the momentary reason
1497
+ or `null` for a verdict.
1498
+
1499
+ The readers are exact-key. Pallet 36.3.14 reads `code` and `reason` and tolerates
1500
+ other members, but a reader built on this contract refuses a payload carrying
1501
+ any other member, and a refused payload reads as no refusal, which turns a
1502
+ momentary refusal into a verdict. A producer never adds a member without gating
1503
+ it on the peer's spoken interfaces version.
1504
+
1505
+ `data.runtimeSessionRequestBounds` orders the hops so the one nearest the
1506
+ decision answers first and a node always reads a stated refusal rather than its
1507
+ own timeout: Cloudly's `controllerBudgetMs` (6 s, every node request including
1508
+ registration); a relay's `relayRegistrationBudgetMs` (9 s from arrival to
1509
+ answer, more than a second beyond Cloudly's budget and below TypedSocket's 10 s
1510
+ connection-restore deadline); and a relay's `relaySessionForwardMs` (8 s) for a
1511
+ session read or report forwarded over its established Cloudly link.
1512
+
1455
1513
  ### Assignment-Bound Runtime Secrets
1456
1514
 
1457
1515
  `@serve.zone/interfaces/runtime` exports the Pallet assignment-secret contract.
@@ -3611,9 +3669,25 @@ The catalog validator judges every supported version config in full; no consumer
3611
3669
 
3612
3670
  - `image`, then the port model (`appstore.validateAppStoreVersionPortModel`): `port` or `targetPorts` is required, and `targetPorts`, `domains` and `publicPortMappings` are read as exactly their types and resolved through `data.normalizeServicePortConfig`.
3613
3671
  - `containerArgs` and the environment declarations.
3614
- - Storage (`appstore.validateAppStoreVersionStorage`): the exact config member set (`appstore.appStoreVersionConfigKeys`), `platformRequirements`, `storageClasses` and `storageRequests` (declared together, every class used), the storage feature ids `requiresFeatures` must name, portable legacy `volumes`, and every environment, secret-file and mount destination used once.
3672
+ - Storage (`appstore.validateAppStoreVersionStorage`): the exact config member set (`appstore.appStoreVersionConfigKeys`), `platformRequirements`, `storageClasses` and `storageRequests` (declared together, every class used), `backupExclusions`, the storage feature ids `requiresFeatures` must name, portable legacy `volumes`, and every environment, secret-file and mount destination used once.
3615
3673
  - Platform OIDC, then Docker `publishedPorts` (`appstore.validateAppStorePublishedPorts`).
3616
3674
 
3675
+ A version declares data it keeps only for observability, such as workload logs, as `backupExclusions` (`IAppStoreBackupExclusion[]`): a backup leaves those object keys out, and a restore neither brings them back nor removes them. Each entry names one objectStorage request of the version (`storageRequest`: an explicit request id, or `legacy-s3` when the version declares `platformRequirements.s3`; a filesystem request is refused), 1 to 16 `keyPrefixes` and a one-line `reason` an operator reads:
3676
+
3677
+ ```typescript
3678
+ const cloudlyVersion: Partial<appstore.IAppStoreVersionConfig> = {
3679
+ platformRequirements: { s3: true },
3680
+ backupExclusions: [{
3681
+ storageRequest: 'legacy-s3',
3682
+ keyPrefixes: ['runtime-logs/'],
3683
+ reason: 'Workload logs; a restore brings back state, not past logs.',
3684
+ }],
3685
+ requiresFeatures: [appstore.appStoreStorageFeatureIds.objectStorageBackupExclusions],
3686
+ };
3687
+ ```
3688
+
3689
+ Entries are in strictly increasing `storageRequest` order with each request at most once. A key prefix is 1 to 512 UTF-8 bytes, ends in `/`, does not start with `/`, has no empty, `.` or `..` segment and no control character; the prefixes of an entry are strictly increasing and none lies inside another. `appstore.appStoreBackupExclusionLimits` states the bounds, and `appstore.validateAppStoreBackupExclusionKeyPrefixes(value, label?)` is the one prefix-list rule: the version-config check applies it to each entry, and a reader of a list carrying such prefixes elsewhere, such as a backup manifest, judges it there too (an empty list means valid, otherwise one `Invalid <label>` reason). A version declaring any entry names `storage.object-storage.backup-exclusions` (`appStoreStorageFeatureIds.objectStorageBackupExclusions`) in `requiresFeatures`, so a host that does not leave the keys out of its backups lists the version without installing it, and an older host never reads the member.
3690
+
3617
3691
  Each storage request is judged on its own by the storage contract behind `platform.normalizeStorageRequest`: members, capacity quantities and limits, protection, delivery and mount path, with that contract's messages and bounds, and a legacy volume's mount path as the request it becomes. The version config adds what only holds between requests: each request's class is declared, of its kind and backs a declared limit (`hardQuota`), backup or snapshots; ids, environment keys, secret files and mounts are used once; the legacy names `legacyFilesystem`, `legacyObjectStorage` and `legacy-s3` stay reserved. Two App Store narrowings apply on top: a request id is a stable lowercase identifier (it shares a namespace with the derived `legacy-volume-*` ids), and a file delivery targets one file directly below `/run/secrets`.
3618
3692
 
3619
3693
  Each of these validators stops at the first violation and answers it as a one-element list starting `Invalid <label>`, so a config is refused for the same reason everywhere.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '32.55.1',
6
+ version: '32.57.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
@@ -86,6 +86,7 @@ const versionConfigKeyList = [
86
86
  'volumes',
87
87
  'storageClasses',
88
88
  'storageRequests',
89
+ 'backupExclusions',
89
90
  'publishedPorts',
90
91
  'platformRequirements',
91
92
  'platformOidc',
@@ -62,6 +62,8 @@ export const appStoreStorageFeatureIds = {
62
62
  filesystem: 'storage.filesystem',
63
63
  objectStorage: 'storage.object-storage',
64
64
  objectStorageFile: 'storage.object-storage.file',
65
+ /** The version declares `backupExclusions`; a consumer naming it leaves those keys out of backups. */
66
+ objectStorageBackupExclusions: 'storage.object-storage.backup-exclusions',
65
67
  } as const;
66
68
 
67
69
  export type TAppStoreStorageFeatureId =
@@ -127,6 +129,35 @@ export type TAppStoreStorageRequest =
127
129
  | IAppStoreFilesystemStorageRequest
128
130
  | IAppStoreObjectStorageRequest;
129
131
 
132
+ /** The bounds of `IAppStoreVersionConfig.backupExclusions`. */
133
+ export const appStoreBackupExclusionLimits = Object.freeze({
134
+ /** At most one entry per object-storage request; a version config holds at most 64 requests. */
135
+ maximumEntries: 64,
136
+ maximumKeyPrefixes: 16,
137
+ maximumKeyPrefixBytes: 512,
138
+ maximumReasonLength: 512,
139
+ } as const);
140
+
141
+ /**
142
+ * Keys of one object-storage request that the app keeps only for observability, such as its
143
+ * workload logs. A backup leaves them out; a restore neither brings them back nor removes them.
144
+ */
145
+ export interface IAppStoreBackupExclusion {
146
+ /**
147
+ * An objectStorage request of this version: an explicit request id, or `legacy-s3` when the
148
+ * version declares `platformRequirements.s3`. A filesystem request cannot declare exclusions.
149
+ */
150
+ storageRequest: string;
151
+ /**
152
+ * 1 to 16 object key prefixes in strictly increasing UTF-16 code-unit order, none a prefix of
153
+ * another. Each is 1 to 512 UTF-8 bytes, ends in `/`, does not start with `/`, has no empty,
154
+ * `.` or `..` segment and no control character or unpaired surrogate.
155
+ */
156
+ keyPrefixes: string[];
157
+ /** One operator-readable line, e.g. "Workload logs; a restore brings back state, not past logs." */
158
+ reason: string;
159
+ }
160
+
130
161
  export interface IAppStoreInlineSource {
131
162
  type: 'inline';
132
163
  }
@@ -402,6 +433,13 @@ export interface IAppStoreVersionConfig {
402
433
  storageClasses?: Record<string, IAppStoreStorageClass>;
403
434
  /** Stable named filesystem and managed object-storage requests. */
404
435
  storageRequests?: TAppStoreStorageRequest[];
436
+ /**
437
+ * Data the app keeps only for observability, in strictly increasing `storageRequest` order with
438
+ * each request at most once. A backup leaves it out; a restore neither brings it back nor
439
+ * removes it. A version declaring any entry names
440
+ * `appStoreStorageFeatureIds.objectStorageBackupExclusions` in `requiresFeatures`.
441
+ */
442
+ backupExclusions?: IAppStoreBackupExclusion[];
405
443
  publishedPorts?: IAppStorePublishedPort[];
406
444
  platformRequirements?: IAppStorePlatformRequirements;
407
445
  /**
@@ -15,7 +15,8 @@
15
15
  * `platform.normalizeStorageRequest`), reporting through this module's rejection. What this module
16
16
  * adds are the rules a request can only break inside a version config: its class is declared and of
17
17
  * its kind, the class backs a declared limit or protection, ids and destinations are used once, the
18
- * legacy names stay reserved, and the storage feature ids are named.
18
+ * legacy names stay reserved, a backup exclusion names an object-storage request, and the storage
19
+ * feature ids are named.
19
20
  */
20
21
  import { normalizeServiceAbsolutePath, validateServiceContainerArgs } from '../data/service.js';
21
22
  import { normalizeServicePortConfig } from '../data/serviceports.js';
@@ -26,16 +27,22 @@ import type {
26
27
  TServiceTargetPortRef,
27
28
  TServiceTargetProtocol,
28
29
  } from '../data/serviceports.js';
30
+ import { compareWireTokens } from '../private/canonicaljson.js';
29
31
  import { readDataElements, readDataProperties } from '../private/dataproperties.js';
30
32
  import type { TCompleteKeyList } from '../private/keylist.js';
31
33
  import { createStorageContractReaders } from '../private/storagecontract.js';
32
34
  import type { TObjectStorageDelivery } from '../platform/storage.js';
33
35
  import {
36
+ appStoreBackupExclusionLimits,
34
37
  appStoreStorageFeatureIds,
35
38
  validateAppStoreEnvironmentDeclarations,
36
39
  validateAppStoreVersionPlatformOidc,
37
40
  } from './types.js';
38
- import type { IAppStoreVersionConfig, TAppStoreStorageRequest } from './types.js';
41
+ import type {
42
+ IAppStoreBackupExclusion,
43
+ IAppStoreVersionConfig,
44
+ TAppStoreStorageRequest,
45
+ } from './types.js';
39
46
 
40
47
  const versionConfigMemberList = [
41
48
  'image',
@@ -48,6 +55,7 @@ const versionConfigMemberList = [
48
55
  'volumes',
49
56
  'storageClasses',
50
57
  'storageRequests',
58
+ 'backupExclusions',
51
59
  'publishedPorts',
52
60
  'platformRequirements',
53
61
  'platformOidc',
@@ -92,6 +100,8 @@ const legacyObjectStorageClassKey = 'legacyObjectStorage';
92
100
  const legacyObjectStorageRequestId = 'legacy-s3';
93
101
  const knownStorageFeatureIds: readonly string[] = Object.values(appStoreStorageFeatureIds);
94
102
  const platformRequirementKeys = ['mongodb', 's3', 'clickhouse', 'valkey', 'mariadb', 'mail'] as const;
103
+ const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
104
+ const utf8Encoder = new TextEncoder();
95
105
 
96
106
  /** Thrown inside this module only; every exported validator answers it as its result. */
97
107
  class AppStoreVersionConfigRejection extends Error {}
@@ -601,6 +611,7 @@ function checkExplicitStorageRequests(
601
611
  function checkFeatureDeclaration(
602
612
  requiresFeaturesArg: unknown,
603
613
  explicitRequestsArg: readonly TAppStoreStorageRequest[],
614
+ backupExclusionsArg: readonly IAppStoreBackupExclusion[],
604
615
  labelArg: string,
605
616
  ): void {
606
617
  const derivedFeatures = new Set<string>();
@@ -614,6 +625,9 @@ function checkFeatureDeclaration(
614
625
  if (explicitRequestsArg.some((requestArg) => requestArg.kind === 'objectStorage' && requestArg.delivery.type === 'file')) {
615
626
  derivedFeatures.add(appStoreStorageFeatureIds.objectStorageFile);
616
627
  }
628
+ if (backupExclusionsArg.length > 0) {
629
+ derivedFeatures.add(appStoreStorageFeatureIds.objectStorageBackupExclusions);
630
+ }
617
631
 
618
632
  const declaredEntries = requiresFeaturesArg === undefined
619
633
  ? []
@@ -641,6 +655,123 @@ function checkFeatureDeclaration(
641
655
  }
642
656
  }
643
657
 
658
+ const hasUnpairedSurrogate = (valueArg: string): boolean => {
659
+ for (let index = 0; index < valueArg.length; index++) {
660
+ const codeUnit = valueArg.charCodeAt(index);
661
+ if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
662
+ const following = valueArg.charCodeAt(index + 1);
663
+ if (!(following >= 0xdc00 && following <= 0xdfff)) return true;
664
+ index++;
665
+ } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
666
+ return true;
667
+ }
668
+ }
669
+ return false;
670
+ };
671
+
672
+ /** One object key prefix: a relative key path that names a whole subtree. */
673
+ function checkBackupExclusionKeyPrefix(valueArg: unknown, labelArg: string): string {
674
+ if (typeof valueArg !== 'string') return reject(`Invalid ${labelArg}: expected a string`);
675
+ if (controlCharacterPattern.test(valueArg) || hasUnpairedSurrogate(valueArg)) {
676
+ reject(`Invalid ${labelArg}: must not contain a control character or an unpaired surrogate`);
677
+ }
678
+ const byteLength = utf8Encoder.encode(valueArg).byteLength;
679
+ if (byteLength < 1 || byteLength > appStoreBackupExclusionLimits.maximumKeyPrefixBytes) {
680
+ reject(`Invalid ${labelArg}: must be 1 to ${appStoreBackupExclusionLimits.maximumKeyPrefixBytes} UTF-8 bytes`);
681
+ }
682
+ if (valueArg.startsWith('/')) reject(`Invalid ${labelArg}: must not start with '/'`);
683
+ if (!valueArg.endsWith('/')) reject(`Invalid ${labelArg}: must end with '/'`);
684
+ if (valueArg.slice(0, -1).split('/').some((segmentArg) => segmentArg === '' || segmentArg === '.' || segmentArg === '..')) {
685
+ reject(`Invalid ${labelArg}: must not contain an empty, '.' or '..' segment`);
686
+ }
687
+ return valueArg;
688
+ }
689
+
690
+ /**
691
+ * One exclusion's key prefix list: 1 to 16 prefixes, strictly increasing, none inside another.
692
+ * `validateAppStoreBackupExclusionKeyPrefixes` exports it, so every reader of a prefix list judges
693
+ * it by this one rule.
694
+ */
695
+ function checkBackupExclusionKeyPrefixes(valueArg: unknown, labelArg: string): string[] {
696
+ const { maximumKeyPrefixes } = appStoreBackupExclusionLimits;
697
+ const prefixEntries = readDataElements(valueArg, maximumKeyPrefixes);
698
+ if (prefixEntries === null || prefixEntries.length === 0) {
699
+ return reject(`Invalid ${labelArg}: expected an array of 1 to ${maximumKeyPrefixes} key prefixes`);
700
+ }
701
+ const keyPrefixes: string[] = [];
702
+ prefixEntries.forEach((prefixArg, indexArg) => {
703
+ const prefixLabel = `${labelArg}[${indexArg}]`;
704
+ const prefix = checkBackupExclusionKeyPrefix(prefixArg, prefixLabel);
705
+ const previousPrefix = keyPrefixes[keyPrefixes.length - 1];
706
+ if (previousPrefix !== undefined && compareWireTokens(previousPrefix, prefix) >= 0) {
707
+ reject(`Invalid ${labelArg}: must be strictly increasing`);
708
+ }
709
+ const covering = keyPrefixes.find((existingArg) => prefix.startsWith(existingArg));
710
+ if (covering !== undefined) {
711
+ reject(`Invalid ${prefixLabel}: '${prefix}' lies inside '${covering}'`);
712
+ }
713
+ keyPrefixes.push(prefix);
714
+ });
715
+ return keyPrefixes;
716
+ }
717
+
718
+ /**
719
+ * The members of each backup exclusion, its key prefixes and the entry order. Which request an
720
+ * entry names is judged once the config's requests are known (`checkBackupExclusionTargets`).
721
+ */
722
+ function checkBackupExclusions(valueArg: unknown, labelArg: string): IAppStoreBackupExclusion[] {
723
+ if (valueArg === undefined) return [];
724
+ const { maximumEntries, maximumReasonLength } = appStoreBackupExclusionLimits;
725
+ const entries = readDataElements(valueArg, maximumEntries);
726
+ if (entries === null || entries.length === 0) {
727
+ return reject(`Invalid ${labelArg}: backupExclusions must contain between 1 and ${maximumEntries} entries`);
728
+ }
729
+ let previousRequest: string | null = null;
730
+ return entries.map((entryArg, indexArg): IAppStoreBackupExclusion => {
731
+ const entryLabel = `${labelArg} backupExclusions[${indexArg}]`;
732
+ const entry = readRecord(entryArg, entryLabel);
733
+ assertExactKeys(entry, ['storageRequest', 'keyPrefixes', 'reason'], entryLabel);
734
+ const { storageRequest, reason } = entry;
735
+ if (typeof storageRequest !== 'string') return reject(`Invalid ${entryLabel}.storageRequest: expected a string`);
736
+ if (previousRequest !== null && compareWireTokens(previousRequest, storageRequest) >= 0) {
737
+ reject(previousRequest === storageRequest
738
+ ? `Invalid ${labelArg}: backupExclusions name storage request '${storageRequest}' more than once`
739
+ : `Invalid ${labelArg}: backupExclusions must be in strictly increasing storageRequest order`);
740
+ }
741
+ previousRequest = storageRequest;
742
+
743
+ const keyPrefixes = checkBackupExclusionKeyPrefixes(entry.keyPrefixes, `${entryLabel}.keyPrefixes`);
744
+
745
+ if (typeof reason !== 'string'
746
+ || reason.length === 0
747
+ || reason.length > maximumReasonLength
748
+ || reason.trim() !== reason
749
+ || controlCharacterPattern.test(reason)
750
+ || hasUnpairedSurrogate(reason)) {
751
+ reject(`Invalid ${entryLabel}.reason: must be one trimmed line of 1 to ${maximumReasonLength} characters`);
752
+ }
753
+ return { storageRequest, keyPrefixes, reason };
754
+ });
755
+ }
756
+
757
+ /** Each backup exclusion names an object-storage request of the normalized config. */
758
+ function checkBackupExclusionTargets(
759
+ exclusionsArg: readonly IAppStoreBackupExclusion[],
760
+ objectStorageRequestIdsArg: ReadonlySet<string>,
761
+ filesystemRequestIdsArg: ReadonlySet<string>,
762
+ labelArg: string,
763
+ ): void {
764
+ exclusionsArg.forEach((exclusionArg, indexArg) => {
765
+ const { storageRequest } = exclusionArg;
766
+ if (objectStorageRequestIdsArg.has(storageRequest)) return;
767
+ const requestLabel = `${labelArg} backupExclusions[${indexArg}].storageRequest`;
768
+ if (filesystemRequestIdsArg.has(storageRequest)) {
769
+ reject(`Invalid ${requestLabel}: '${storageRequest}' is a filesystem request; only an objectStorage request declares backup exclusions`);
770
+ }
771
+ reject(`Invalid ${requestLabel}: '${storageRequest}' does not name an objectStorage request of this version`);
772
+ });
773
+ }
774
+
644
775
  interface ILegacyVolumeFacts {
645
776
  name?: string;
646
777
  mountPath: string;
@@ -710,7 +841,8 @@ function checkStorage(configArg: Record<string, unknown>, labelArg: string): voi
710
841
  destinations,
711
842
  labelArg,
712
843
  );
713
- checkFeatureDeclaration(configArg.requiresFeatures, explicitRequests, labelArg);
844
+ const backupExclusions = checkBackupExclusions(configArg.backupExclusions, labelArg);
845
+ checkFeatureDeclaration(configArg.requiresFeatures, explicitRequests, backupExclusions, labelArg);
714
846
  const legacyS3 = platformRequirements.s3 === true;
715
847
  if (legacyS3 && explicitRequests.some((requestArg) => requestArg.kind === 'objectStorage')) {
716
848
  reject(`Invalid ${labelArg}: platformRequirements.s3 cannot coexist with explicit objectStorage requests`);
@@ -727,6 +859,7 @@ function checkStorage(configArg: Record<string, unknown>, labelArg: string): voi
727
859
  const allMounts: Array<{ id: string; mountPath: string }> = explicitRequests.flatMap((requestArg) => (
728
860
  requestArg.kind === 'filesystem' ? [{ id: requestArg.id, mountPath: requestArg.mountPath }] : []
729
861
  ));
862
+ const legacyVolumeRequestIds: string[] = [];
730
863
  legacyVolumes.forEach((volumeArg, indexArg) => {
731
864
  const requestId = legacyVolumeRequestId(volumeArg, indexArg, labelArg);
732
865
  if (requestIds.has(requestId)) reject(`Invalid ${labelArg}: storage request id '${requestId}' is duplicated`);
@@ -734,6 +867,7 @@ function checkStorage(configArg: Record<string, unknown>, labelArg: string): voi
734
867
  requestIds.add(requestId);
735
868
  mountPaths.push(volumeArg.mountPath);
736
869
  allMounts.push({ id: requestId, mountPath: volumeArg.mountPath });
870
+ legacyVolumeRequestIds.push(requestId);
737
871
  });
738
872
 
739
873
  if (legacyS3) {
@@ -744,6 +878,18 @@ function checkStorage(configArg: Record<string, unknown>, labelArg: string): voi
744
878
  reject(`Invalid ${labelArg}: storage request id '${legacyObjectStorageRequestId}' is reserved for legacy S3 normalization`);
745
879
  }
746
880
  }
881
+ checkBackupExclusionTargets(
882
+ backupExclusions,
883
+ new Set([
884
+ ...explicitRequests.flatMap((requestArg) => (requestArg.kind === 'objectStorage' ? [requestArg.id] : [])),
885
+ ...(legacyS3 ? [legacyObjectStorageRequestId] : []),
886
+ ]),
887
+ new Set([
888
+ ...explicitRequests.flatMap((requestArg) => (requestArg.kind === 'filesystem' ? [requestArg.id] : [])),
889
+ ...legacyVolumeRequestIds,
890
+ ]),
891
+ labelArg,
892
+ );
747
893
 
748
894
  const checkedMounts: string[] = [];
749
895
  for (const mount of allMounts) {
@@ -777,12 +923,24 @@ export const validateAppStorePublishedPorts = (publishedPortsArg: unknown, label
777
923
 
778
924
  /**
779
925
  * The config's member set and its storage: legacy `volumes`, `storageClasses`, `storageRequests`,
780
- * `platformRequirements`, the storage feature ids `requiresFeatures` must name, and every
781
- * environment, secret-file and mount destination being used once.
926
+ * `platformRequirements`, `backupExclusions`, the storage feature ids `requiresFeatures` must name,
927
+ * and every environment, secret-file and mount destination being used once.
782
928
  */
783
929
  export const validateAppStoreVersionStorage = (configArg: unknown, labelArg = defaultLabel): string[] =>
784
930
  firstRejection(() => checkStorage(readConfig(configArg, labelArg), labelArg));
785
931
 
932
+ /**
933
+ * One backup exclusion's `keyPrefixes`, the rule `validateAppStoreVersionStorage` applies to each
934
+ * `IAppStoreBackupExclusion`: 1 to 16 prefixes in strictly increasing UTF-16 code-unit order, none
935
+ * inside another, each 1 to 512 UTF-8 bytes ending in `/`, without a leading `/`, an empty, `.` or
936
+ * `..` segment, a control character or an unpaired surrogate. A reader of a list that carries such
937
+ * prefixes elsewhere, such as a backup manifest, judges it here.
938
+ */
939
+ export const validateAppStoreBackupExclusionKeyPrefixes = (
940
+ valueArg: unknown,
941
+ labelArg = 'keyPrefixes',
942
+ ): string[] => firstRejection(() => checkBackupExclusionKeyPrefixes(valueArg, labelArg));
943
+
786
944
  /**
787
945
  * Every member rule of a version config, in the order a host applies them: image, port model,
788
946
  * container arguments, environment declarations, storage, platform OIDC, published ports.
package/ts/data/index.ts CHANGED
@@ -66,6 +66,7 @@ export * from './runtimestorageclaim.js';
66
66
  export * from './runtimelocalstorageclaim.js';
67
67
  export * from './runtimelocalvolumearchive.js';
68
68
  export * from './runtimesession.js';
69
+ export * from './runtimesessionrefusal.js';
69
70
  export * from './runtimeterminalreceipt.js';
70
71
  export * from './secret.js';
71
72
  export * from './baremetal.js';