@ankhorage/contracts 21.0.0 → 22.0.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.
Files changed (106) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +3 -1
  3. package/dist/appManifest/apis.d.ts +3 -3
  4. package/dist/appManifest/apis.d.ts.map +1 -1
  5. package/dist/appManifest/apis.js +4 -6
  6. package/dist/appManifest/apis.js.map +1 -1
  7. package/dist/appManifest/bindings.d.ts.map +1 -1
  8. package/dist/appManifest/bindings.js +2 -1
  9. package/dist/appManifest/bindings.js.map +1 -1
  10. package/dist/appManifest/data.d.ts.map +1 -1
  11. package/dist/appManifest/data.js +5 -4
  12. package/dist/appManifest/data.js.map +1 -1
  13. package/dist/appManifest/isAppManifest.js +3 -3
  14. package/dist/appManifest/isAppManifest.js.map +1 -1
  15. package/dist/appManifest/isManifestValue.d.ts +1 -1
  16. package/dist/appManifest/isManifestValue.d.ts.map +1 -1
  17. package/dist/appManifest/isManifestValue.js +3 -12
  18. package/dist/appManifest/isManifestValue.js.map +1 -1
  19. package/dist/appManifest/screens.d.ts +3 -2
  20. package/dist/appManifest/screens.d.ts.map +1 -1
  21. package/dist/appManifest/screens.js +15 -7
  22. package/dist/appManifest/screens.js.map +1 -1
  23. package/dist/appManifest/splashScreen.d.ts +8 -11
  24. package/dist/appManifest/splashScreen.d.ts.map +1 -1
  25. package/dist/appManifest/splashScreen.js.map +1 -1
  26. package/dist/bindings.d.ts +2 -3
  27. package/dist/bindings.d.ts.map +1 -1
  28. package/dist/bindings.js.map +1 -1
  29. package/dist/collections.d.ts +4 -0
  30. package/dist/collections.d.ts.map +1 -0
  31. package/dist/collections.js +6 -0
  32. package/dist/collections.js.map +1 -0
  33. package/dist/data/apis.d.ts +1 -1
  34. package/dist/data/apis.d.ts.map +1 -1
  35. package/dist/data/apis.js.map +1 -1
  36. package/dist/data/values.d.ts +2 -3
  37. package/dist/data/values.d.ts.map +1 -1
  38. package/dist/data/values.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/infra/isInfraEnvironmentSpec.d.ts.map +1 -1
  44. package/dist/infra/isInfraEnvironmentSpec.js +10 -12
  45. package/dist/infra/isInfraEnvironmentSpec.js.map +1 -1
  46. package/dist/infra/isInfraManifest.d.ts.map +1 -1
  47. package/dist/infra/isInfraManifest.js +14 -5
  48. package/dist/infra/isInfraManifest.js.map +1 -1
  49. package/dist/infra/isInfraWorkloadSpec.d.ts.map +1 -1
  50. package/dist/infra/isInfraWorkloadSpec.js +26 -20
  51. package/dist/infra/isInfraWorkloadSpec.js.map +1 -1
  52. package/dist/requirements.d.ts +5 -10
  53. package/dist/requirements.d.ts.map +1 -1
  54. package/dist/requirements.js.map +1 -1
  55. package/dist/serializable.d.ts +6 -0
  56. package/dist/serializable.d.ts.map +1 -0
  57. package/dist/serializable.js +14 -0
  58. package/dist/serializable.js.map +1 -0
  59. package/dist/state.d.ts +2 -3
  60. package/dist/state.d.ts.map +1 -1
  61. package/dist/state.js.map +1 -1
  62. package/dist/types/infraManifest.d.ts +14 -13
  63. package/dist/types/infraManifest.d.ts.map +1 -1
  64. package/dist/types/infraManifest.js.map +1 -1
  65. package/dist/types/infraWorkload.d.ts +11 -11
  66. package/dist/types/infraWorkload.d.ts.map +1 -1
  67. package/dist/types/infraWorkload.js.map +1 -1
  68. package/dist/types.d.ts +9 -8
  69. package/dist/types.d.ts.map +1 -1
  70. package/dist/types.js.map +1 -1
  71. package/package.json +2 -3
  72. package/src/appManifest/apis.ts +9 -6
  73. package/src/appManifest/bindings.ts +7 -1
  74. package/src/appManifest/data.ts +13 -4
  75. package/src/appManifest/isAppManifest.ts +3 -3
  76. package/src/appManifest/isManifestValue.ts +3 -16
  77. package/src/appManifest/screens.ts +21 -10
  78. package/src/appManifest/splashScreen.test.ts +3 -3
  79. package/src/appManifest/splashScreen.ts +8 -18
  80. package/src/appManifest.test.ts +21 -16
  81. package/src/bindings.test.ts +11 -8
  82. package/src/bindings.ts +2 -9
  83. package/src/collections.ts +8 -0
  84. package/src/contracts.test.ts +16 -13
  85. package/src/data/apis.test.ts +6 -4
  86. package/src/data/apis.ts +1 -1
  87. package/src/data/values.ts +3 -9
  88. package/src/deploy.test.ts +4 -4
  89. package/src/index.ts +2 -0
  90. package/src/infra/isInfraEnvironmentSpec.ts +16 -16
  91. package/src/infra/isInfraManifest.ts +17 -6
  92. package/src/infra/isInfraWorkloadSpec.ts +39 -24
  93. package/src/infra-published-port.test.ts +8 -8
  94. package/src/infra-workload-persistence.test.ts +4 -4
  95. package/src/infra-workload.test.ts +24 -22
  96. package/src/infra.test.ts +16 -20
  97. package/src/infraPersistence.test.ts +1 -1
  98. package/src/infraServices.test.ts +1 -1
  99. package/src/requirements.test.ts +2 -2
  100. package/src/requirements.ts +6 -12
  101. package/src/secrets.test.ts +1 -1
  102. package/src/serializable.ts +25 -0
  103. package/src/state.ts +3 -6
  104. package/src/types/infraManifest.ts +17 -13
  105. package/src/types/infraWorkload.ts +12 -11
  106. package/src/types.ts +11 -12
@@ -2,6 +2,7 @@ import { isStringArray } from '@ankhorage/utility/array';
2
2
  import { isRecord } from '@ankhorage/utility/object';
3
3
  import { isNonEmptyString } from '@ankhorage/utility/string';
4
4
 
5
+ import { isSerializableSet } from '../collections';
5
6
  import type { InfraShape } from '../types/infraValidation';
6
7
  import type { InfraWorkloadSpec } from '../types/infraWorkload';
7
8
  import { infraFields } from './infraFields';
@@ -18,15 +19,11 @@ export function isInfraWorkloadSpec(value: unknown): value is InfraWorkloadSpec
18
19
  isInfraShape(artifact, { kind: (kind) => kind === 'image', image: isNonEmptyString }),
19
20
  command: infraFields.optionalStrings,
20
21
  args: (args) => args === undefined || isStringArray(args),
21
- ports: (ports) =>
22
- ports === undefined ||
23
- (Array.isArray(ports) && ports.every(isPort) && hasUniqueField(ports, 'name')),
22
+ ports: (ports) => ports === undefined || isPortRegistry(ports),
24
23
  environment: (environment) =>
25
24
  environment === undefined ||
26
25
  (isRecord(environment) && Object.values(environment).every(isInfraWorkloadValue)),
27
- files: (files) =>
28
- files === undefined ||
29
- (Array.isArray(files) && files.every(isFile) && hasUniqueField(files, 'path')),
26
+ files: (files) => files === undefined || isFileMap(files),
30
27
  health: (health) => health === undefined || isInfraWorkloadHealth(health),
31
28
  resources: (resources) =>
32
29
  resources === undefined ||
@@ -34,16 +31,11 @@ export function isInfraWorkloadSpec(value: unknown): value is InfraWorkloadSpec
34
31
  cpuMillis: infraFields.optionalPositiveInteger,
35
32
  memoryMiB: infraFields.optionalPositiveInteger,
36
33
  }),
37
- persistence: (volumes) =>
38
- volumes === undefined ||
39
- (Array.isArray(volumes) &&
40
- volumes.every(isVolume) &&
41
- hasUniqueField(volumes, 'id') &&
42
- hasUniqueField(volumes, 'mountPath')),
34
+ persistence: (volumes) => volumes === undefined || isVolumeRegistry(volumes),
43
35
  exposure: (exposure) =>
44
36
  exposure === undefined || exposure === 'internal' || exposure === 'public',
45
37
  replicas: infraFields.optionalNonnegativeInteger,
46
- dependsOn: infraFields.optionalStrings,
38
+ dependsOn: (dependsOn) => dependsOn === undefined || isSerializableSet(dependsOn),
47
39
  } satisfies InfraShape<InfraWorkloadSpec>)
48
40
  ) {
49
41
  return false;
@@ -51,10 +43,17 @@ export function isInfraWorkloadSpec(value: unknown): value is InfraWorkloadSpec
51
43
  return hasValidPublishedPorts(value);
52
44
  }
53
45
 
46
+ /*** Validate named transport ports keyed by their stable authored name. */
47
+ function isPortRegistry(value: unknown): boolean {
48
+ return (
49
+ isRecord(value) &&
50
+ Object.entries(value).every(([name, port]) => isNonEmptyString(name) && isPort(port))
51
+ );
52
+ }
53
+
54
54
  /*** Named transport ports stay independent from runtime resource types. */
55
55
  function isPort(value: unknown): boolean {
56
56
  return isInfraShape(value, {
57
- name: isNonEmptyString,
58
57
  port: infraFields.port,
59
58
  protocol: (protocol) => protocol === undefined || protocol === 'tcp' || protocol === 'udp',
60
59
  publishedPort: infraFields.optionalPort,
@@ -64,7 +63,7 @@ function isPort(value: unknown): boolean {
64
63
  /*** Require fixed external listeners to belong to one public workload replica. */
65
64
  function hasValidPublishedPorts(workload: unknown): boolean {
66
65
  if (!isRecord(workload)) return false;
67
- const ports = Array.isArray(workload.ports) ? workload.ports : [];
66
+ const ports = isRecord(workload.ports) ? Object.values(workload.ports) : [];
68
67
  const published = ports.flatMap((port) => {
69
68
  if (!isRecord(port) || typeof port.publishedPort !== 'number') return [];
70
69
  return [port.publishedPort];
@@ -78,9 +77,31 @@ function hasValidPublishedPorts(workload: unknown): boolean {
78
77
  );
79
78
  }
80
79
 
81
- /*** Portable files model policy/config materialization without host filesystem access. */
82
- function isFile(value: unknown): boolean {
83
- return isInfraShape(value, { path: isAbsoluteWorkloadPath, content: isInfraWorkloadValue });
80
+ /*** Validate portable config/policy files keyed by their absolute container path. */
81
+ function isFileMap(value: unknown): boolean {
82
+ return (
83
+ isRecord(value) &&
84
+ Object.entries(value).every(
85
+ ([filePath, content]) => isAbsoluteWorkloadPath(filePath) && isInfraWorkloadValue(content),
86
+ )
87
+ );
88
+ }
89
+
90
+ /*** Validate persistence volumes by stable ID and keep mount paths unique within one workload. */
91
+ function isVolumeRegistry(value: unknown): boolean {
92
+ if (!isRecord(value)) return false;
93
+ const entries = Object.entries(value);
94
+ if (
95
+ !entries.every(
96
+ ([volumeId, volume]) => isVolume(volume) && isRecord(volume) && volume.id === volumeId,
97
+ )
98
+ ) {
99
+ return false;
100
+ }
101
+ const mountPaths = Object.values(value).map((volume) =>
102
+ isRecord(volume) ? volume.mountPath : undefined,
103
+ );
104
+ return new Set(mountPaths).size === mountPaths.length;
84
105
  }
85
106
 
86
107
  /*** Persistence has explicit ownership-local identity, optional initialization and retention policy. */
@@ -103,9 +124,3 @@ function isAbsoluteWorkloadPath(value: unknown): boolean {
103
124
  !value.split('/').includes('..')
104
125
  );
105
126
  }
106
-
107
- /*** Duplicate workload-local identities would make runtime projection ambiguous. */
108
- function hasUniqueField(values: readonly unknown[], field: string): boolean {
109
- const ids = values.map((value) => (isRecord(value) ? Reflect.get(value, field) : undefined));
110
- return new Set(ids).size === ids.length;
111
- }
@@ -5,7 +5,7 @@ import { isInfraEnvironmentSpec, isInfraWorkloadSpec } from './infra';
5
5
  const publicWorkload = {
6
6
  id: 'api',
7
7
  artifact: { kind: 'image', image: 'example/api:1' },
8
- ports: [{ name: 'http', port: 8080, publishedPort: 18_080 }],
8
+ ports: { http: { port: 8080, publishedPort: 18_080 } },
9
9
  exposure: 'public',
10
10
  } as const;
11
11
 
@@ -17,16 +17,16 @@ describe('published workload ports', () => {
17
17
  });
18
18
 
19
19
  it.each([
20
- { ...publicWorkload, ports: [{ name: 'http', port: 80, publishedPort: 0 }] },
21
- { ...publicWorkload, ports: [{ name: 'http', port: 80, publishedPort: 65_536 }] },
20
+ { ...publicWorkload, ports: { http: { port: 80, publishedPort: 0 } } },
21
+ { ...publicWorkload, ports: { http: { port: 80, publishedPort: 65_536 } } },
22
22
  { ...publicWorkload, exposure: 'internal' },
23
23
  { ...publicWorkload, replicas: 2 },
24
24
  {
25
25
  ...publicWorkload,
26
- ports: [
27
- { name: 'http', port: 80, publishedPort: 8080 },
28
- { name: 'admin', port: 81, publishedPort: 8080 },
29
- ],
26
+ ports: {
27
+ http: { port: 80, publishedPort: 8080 },
28
+ admin: { port: 81, publishedPort: 8080 },
29
+ },
30
30
  },
31
31
  ])('rejects an invalid fixed external listener: %j', (workload) => {
32
32
  expect(isInfraWorkloadSpec(workload)).toBe(false);
@@ -36,7 +36,7 @@ describe('published workload ports', () => {
36
36
  expect(
37
37
  isInfraEnvironmentSpec({
38
38
  deployment: { compute: { provider: 'local' }, runtime: { provider: 'minikube' } },
39
- workloads: [publicWorkload, { ...publicWorkload, id: 'admin' }],
39
+ workloads: { api: publicWorkload, admin: { ...publicWorkload, id: 'admin' } },
40
40
  }),
41
41
  ).toBe(false);
42
42
  });
@@ -5,15 +5,15 @@ import { isInfraWorkloadSpec } from './infra';
5
5
  const workload = {
6
6
  id: 'database',
7
7
  artifact: { kind: 'image', image: 'example/database:1' },
8
- persistence: [
9
- {
8
+ persistence: {
9
+ config: {
10
10
  id: 'config',
11
11
  mountPath: '/etc/database-custom',
12
12
  sizeGiB: 1,
13
13
  seed: 'image',
14
14
  retention: 'retain',
15
15
  },
16
- ],
16
+ },
17
17
  } as const;
18
18
 
19
19
  it('accepts image-seeded persistence initialization', () => {
@@ -24,7 +24,7 @@ it('rejects unsupported persistence initialization modes', () => {
24
24
  expect(
25
25
  isInfraWorkloadSpec({
26
26
  ...workload,
27
- persistence: [{ ...workload.persistence[0], seed: 'empty' }],
27
+ persistence: { config: { ...workload.persistence.config, seed: 'empty' } },
28
28
  }),
29
29
  ).toBe(false);
30
30
  });
@@ -10,6 +10,13 @@ import {
10
10
  } from './infra';
11
11
  import type { AppManifest } from './types';
12
12
 
13
+ const defaultTheme = {
14
+ id: 'default',
15
+ name: 'Default',
16
+ light: { primaryColor: '#3366ff', harmony: 'analogous' },
17
+ dark: { primaryColor: '#6699ff', harmony: 'analogous' },
18
+ } as const;
19
+
13
20
  const reference = {
14
21
  source: 'secret-store',
15
22
  projectId: 'example',
@@ -22,7 +29,7 @@ const workload = {
22
29
  artifact: { kind: 'image', image: 'example/backend@sha256:abc' },
23
30
  command: ['server'],
24
31
  args: ['--verbose'],
25
- ports: [{ name: 'http', port: 8080, publishedPort: 18_080 }],
32
+ ports: { http: { port: 8080, publishedPort: 18_080 } },
26
33
  environment: {
27
34
  NODE_ENV: { kind: 'literal', value: 'production' },
28
35
  DB_PASSWORD: { kind: 'secret', reference },
@@ -45,36 +52,31 @@ const workload = {
45
52
  ],
46
53
  },
47
54
  },
48
- files: [{ path: '/etc/backend/config.json', content: { kind: 'literal', value: '{}' } }],
55
+ files: { '/etc/backend/config.json': { kind: 'literal', value: '{}' } },
49
56
  health: { kind: 'http', port: 8080, path: '/health', intervalSeconds: 5 },
50
57
  resources: { cpuMillis: 500, memoryMiB: 256 },
51
- persistence: [{ id: 'data', mountPath: '/data', sizeGiB: 10, retention: 'retain' }],
58
+ persistence: { data: { id: 'data', mountPath: '/data', sizeGiB: 10, retention: 'retain' } },
52
59
  exposure: 'public',
53
60
  replicas: 1,
54
- dependsOn: ['database'],
61
+ dependsOn: { database: true },
55
62
  } as const satisfies InfraWorkloadSpec;
56
63
 
57
64
  const invalidWorkloads = [
58
65
  { artifact: { kind: 'build', dockerfile: 'Dockerfile' } },
59
66
  { artifact: { kind: 'image', image: '' } },
60
- { ports: [{ name: 'http', port: 0 }] },
61
- { ports: [{ name: 'http', port: 65536 }] },
62
- { ports: [{ name: 'http', port: 1.5 }] },
63
- { ports: [{ name: 'http', port: 80, protocol: 'sctp' }] },
64
- {
65
- ports: [
66
- { name: 'http', port: 80 },
67
- { name: 'http', port: 81 },
68
- ],
69
- },
67
+ { ports: { http: { port: 0 } } },
68
+ { ports: { http: { port: 65536 } } },
69
+ { ports: { http: { port: 1.5 } } },
70
+ { ports: { http: { port: 80, protocol: 'sctp' } } },
71
+ { ports: { '': { port: 80 } } },
70
72
  { replicas: -1 },
71
73
  { replicas: NaN },
72
74
  { replicas: 1.5 },
73
75
  { resources: { memoryMiB: Infinity } },
74
76
  { resources: { cpuMillis: 0 } },
75
- { persistence: [{ id: 'data', mountPath: '/data', sizeGiB: 1 }] },
76
- { persistence: [{ id: 'data', mountPath: '../data', sizeGiB: 1, retention: 'retain' }] },
77
- { files: [{ path: '/etc/../secret', content: { kind: 'literal', value: 'x' } }] },
77
+ { persistence: { data: { id: 'data', mountPath: '/data', sizeGiB: 1 } } },
78
+ { persistence: { data: { id: 'data', mountPath: '../data', sizeGiB: 1, retention: 'retain' } } },
79
+ { files: { '/etc/../secret': { kind: 'literal', value: 'x' } } },
78
80
  { health: { kind: 'http', port: 80, path: '/health', failureThreshold: 0 } },
79
81
  { health: { kind: 'tcp', port: 80, path: '/health' } },
80
82
  { health: { kind: 'command', command: [] } },
@@ -134,7 +136,7 @@ describe('runtime-neutral workload boundary', () => {
134
136
  expect(
135
137
  isInfraEnvironmentSpec({
136
138
  deployment: { compute: { provider: 'local' }, runtime: { provider } },
137
- workloads: [workload],
139
+ workloads: { backend: workload },
138
140
  }),
139
141
  ).toBe(true);
140
142
  }
@@ -156,11 +158,11 @@ describe('runtime-neutral workload boundary', () => {
156
158
  ).toBe(true);
157
159
  });
158
160
 
159
- it('rejects duplicate workload identities', () => {
161
+ it('rejects workload registries whose key disagrees with workload id', () => {
160
162
  expect(
161
163
  isInfraEnvironmentSpec({
162
164
  deployment: { compute: { provider: 'local' }, runtime: { provider: 'k3s' } },
163
- workloads: [workload, workload],
165
+ workloads: { duplicate: workload },
164
166
  }),
165
167
  ).toBe(false);
166
168
  });
@@ -266,7 +268,7 @@ describe('application state boundary', () => {
266
268
  category: 'developer_tools',
267
269
  themeId: 'default',
268
270
  },
269
- themes: [],
271
+ themes: { default: defaultTheme },
270
272
  activeThemeId: 'default',
271
273
  screens: {},
272
274
  navigator: { type: 'stack', routes: [] },
@@ -276,7 +278,7 @@ describe('application state boundary', () => {
276
278
  deployment: { compute: { provider: 'local' }, runtime: { provider: 'minikube' } },
277
279
  },
278
280
  },
279
- modules: [],
281
+ modules: {},
280
282
  },
281
283
  settings: { localization: { defaultLocale: 'en', locales: ['en'] } },
282
284
  } satisfies AppManifest;
package/src/infra.test.ts CHANGED
@@ -23,7 +23,7 @@ const production = {
23
23
  },
24
24
  database: { provider: 'supabase' },
25
25
  auth: { provider: 'supabase' },
26
- objectStorage: { provider: 'r2', buckets: ['media'] },
26
+ objectStorage: { provider: 'r2', buckets: { media: true } },
27
27
  authz: { provider: 'cerbos', kind: 'ABAC' },
28
28
  secretStore: { provider: 'supabase-vault' },
29
29
  networking: { domain: 'api.example.ch', publicBaseUrl: 'https://api.example.ch' },
@@ -33,7 +33,7 @@ describe('standalone Infra environments', () => {
33
33
  it('parses local/preview/production without an AppManifest or Deploy', () => {
34
34
  const manifest = {
35
35
  environments: { local, preview: production, production },
36
- modules: [],
36
+ modules: {},
37
37
  } satisfies InfraManifest;
38
38
  expect(parseInfraManifest(JSON.parse(JSON.stringify(manifest)))).toEqual({
39
39
  ok: true,
@@ -45,14 +45,14 @@ describe('standalone Infra environments', () => {
45
45
  it.each([
46
46
  null,
47
47
  {},
48
- { modules: [] },
49
- { environments: {}, modules: [] },
50
- { environments: { production }, modules: [] },
51
- { environments: { local, staging: local }, modules: [] },
52
- { environments: { local, preview: null }, modules: [] },
53
- { environments: { local }, modules: [42] },
54
- { environments: { local }, modules: [], modulesConfig: [] },
55
- { environments: { local }, modules: [], apis: 'invalid' },
48
+ { modules: {} },
49
+ { environments: {}, modules: {} },
50
+ { environments: { production }, modules: {} },
51
+ { environments: { local, staging: local }, modules: {} },
52
+ { environments: { local, preview: null }, modules: {} },
53
+ { environments: { local }, modules: { invalid: 42 } },
54
+ { environments: { local }, modules: {}, modulesConfig: {} },
55
+ { environments: { local }, modules: {}, apis: 'invalid' },
56
56
  ])('rejects invalid environment boundaries: %j', (value) => {
57
57
  expect(isInfraManifest(value)).toBe(false);
58
58
  expect(parseInfraManifest(value).ok).toBe(false);
@@ -69,7 +69,7 @@ describe('standalone Infra environments', () => {
69
69
  'plugins',
70
70
  'pluginsConfig',
71
71
  ])('rejects removed top-level %s', (key) => {
72
- expect(isInfraManifest({ environments: { local }, modules: [], [key]: {} })).toBe(false);
72
+ expect(isInfraManifest({ environments: { local }, modules: {}, [key]: {} })).toBe(false);
73
73
  });
74
74
 
75
75
  it.each(['storage', 'state', 'monitoring', 'observability', 'target'])(
@@ -148,21 +148,17 @@ describe('Cerbos policy configuration', () => {
148
148
  authz: {
149
149
  provider: 'cerbos',
150
150
  kind: 'ABAC',
151
- policies: [{ path: 'app.yaml', content: 'apiVersion: api.cerbos.dev/v1' }],
151
+ policies: { 'app.yaml': 'apiVersion: api.cerbos.dev/v1' },
152
152
  },
153
153
  }),
154
154
  ).toBe(true);
155
155
  });
156
156
 
157
157
  it.each([
158
- { policies: [{ path: '../app.yaml', content: 'x' }] },
159
- {
160
- policies: [
161
- { path: 'app.yaml', content: 'x' },
162
- { path: 'app.yaml', content: 'y' },
163
- ],
164
- },
165
- ])('rejects unsafe or duplicate policy files: %j', ({ policies }) => {
158
+ { policies: { '../app.yaml': 'x' } },
159
+ { policies: { 'app.yaml': 42 } },
160
+ { policies: [{ path: 'app.yaml', content: 'legacy-array-shape' }] },
161
+ ])('rejects unsafe or non-canonical policy files: %j', ({ policies }) => {
166
162
  expect(
167
163
  isInfraEnvironmentSpec({
168
164
  ...local,
@@ -34,7 +34,7 @@ it('accepts scheduled database backups and an S3-backed Supabase storage service
34
34
  },
35
35
  objectStorage: {
36
36
  provider: 'supabase',
37
- buckets: ['media'],
37
+ buckets: { media: true },
38
38
  backend: { ...target, bucket: 'ankhorage-storage' },
39
39
  },
40
40
  }),
@@ -12,7 +12,7 @@ const production = {
12
12
  },
13
13
  database: { provider: 'supabase' },
14
14
  auth: { provider: 'supabase' },
15
- objectStorage: { provider: 'r2', buckets: ['media'] },
15
+ objectStorage: { provider: 'r2', buckets: { media: true } },
16
16
  authz: { provider: 'cerbos', kind: 'ABAC' },
17
17
  secretStore: { provider: 'supabase-vault' },
18
18
  networking: { domain: 'api.example.ch', publicBaseUrl: 'https://api.example.ch' },
@@ -11,10 +11,10 @@ describe('platform requirements', () => {
11
11
  it('exports ebookReader as the canonical reader capability', () => {
12
12
  const capability: AnkhorageCapabilityName = 'ebookReader';
13
13
  const componentRequirements: ComponentRequirements = {
14
- capabilities: [{ capability }],
14
+ capabilities: { [capability]: true },
15
15
  };
16
16
  const screenRequirements: ScreenRequirements = {
17
- capabilities: [{ capability }],
17
+ capabilities: { [capability]: true },
18
18
  };
19
19
 
20
20
  expect(ANKHORAGE_CAPABILITY_NAMES).toContain(capability);
@@ -1,3 +1,5 @@
1
+ import type { SerializableSet } from './collections';
2
+
1
3
  export const ANKHORAGE_PERMISSION_NAMES = [
2
4
  'camera',
3
5
  'microphone',
@@ -24,20 +26,12 @@ export const ANKHORAGE_CAPABILITY_NAMES = [
24
26
 
25
27
  export type AnkhorageCapabilityName = (typeof ANKHORAGE_CAPABILITY_NAMES)[number];
26
28
 
27
- export interface ScreenPermissionRequirement {
28
- readonly permission: AnkhoragePermissionName;
29
- }
30
-
31
- export interface ScreenCapabilityRequirement {
32
- readonly capability: AnkhorageCapabilityName;
33
- }
34
-
35
29
  export interface ScreenRequirements {
36
- readonly permissions?: readonly ScreenPermissionRequirement[];
37
- readonly capabilities?: readonly ScreenCapabilityRequirement[];
30
+ readonly permissions?: SerializableSet<AnkhoragePermissionName>;
31
+ readonly capabilities?: SerializableSet<AnkhorageCapabilityName>;
38
32
  }
39
33
 
40
34
  export interface ComponentRequirements {
41
- readonly permissions?: readonly ScreenPermissionRequirement[];
42
- readonly capabilities?: readonly ScreenCapabilityRequirement[];
35
+ readonly permissions?: SerializableSet<AnkhoragePermissionName>;
36
+ readonly capabilities?: SerializableSet<AnkhorageCapabilityName>;
43
37
  }
@@ -67,7 +67,7 @@ describe('secret-store contracts', () => {
67
67
  };
68
68
 
69
69
  const infra: InfraManifest = {
70
- modules: [],
70
+ modules: {},
71
71
  environments: {
72
72
  local: {
73
73
  deployment: { compute: { provider: 'local' }, runtime: { provider: 'minikube' } },
@@ -0,0 +1,25 @@
1
+ import { isRecord } from '@ankhorage/utility/object';
2
+
3
+ export type SerializableValue =
4
+ | string
5
+ | number
6
+ | boolean
7
+ | null
8
+ | readonly SerializableValue[]
9
+ | {
10
+ readonly [key: string]: SerializableValue;
11
+ };
12
+
13
+ /*** Validate recursively serializable manifest/config values without accepting functions or host objects. */
14
+ export function isSerializableValue(value: unknown): value is SerializableValue {
15
+ if (
16
+ value === null ||
17
+ typeof value === 'string' ||
18
+ typeof value === 'number' ||
19
+ typeof value === 'boolean'
20
+ ) {
21
+ return true;
22
+ }
23
+ if (Array.isArray(value)) return value.every(isSerializableValue);
24
+ return isRecord(value) && Object.values(value).every(isSerializableValue);
25
+ }
package/src/state.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { SerializableValue } from './serializable';
2
+
1
3
  /** Legend currently implements in-memory state only; unsupported persistence cannot be selected. */
2
4
  export interface AppStateSpec {
3
5
  readonly provider: 'legend';
@@ -6,12 +8,7 @@ export interface AppStateSpec {
6
8
 
7
9
  export type StatePrimitive = string | number | boolean | null;
8
10
 
9
- export type StateValue =
10
- | StatePrimitive
11
- | readonly StateValue[]
12
- | {
13
- readonly [key: string]: StateValue;
14
- };
11
+ export type StateValue = SerializableValue;
15
12
 
16
13
  export type StatePath = string | readonly string[];
17
14
 
@@ -1,10 +1,11 @@
1
1
  import type { AuthFlowConfig, AuthOAuthConfig } from '../auth';
2
- import type { ApiDefinitionList } from '../data';
2
+ import type { SerializableSet } from '../collections';
3
+ import type { ApiDefinitionRegistry, DataContractValue } from '../data';
3
4
  import type { AppEnvironmentId } from '../environments';
4
5
  import type { INFRA_ADAPTER_CATALOG, INFRA_RUNTIME_COMPATIBILITY } from '../infra/constants';
5
6
  import type { AuthProfileSpec, AuthScope, AuthSignInSpec, AuthSignUpSpec } from '../types';
6
7
  import type { InfraControlPlaneCredentialRef } from './infraSecrets';
7
- import type { InfraWorkloadSpec } from './infraWorkload';
8
+ import type { InfraWorkloadRegistry } from './infraWorkload';
8
9
 
9
10
  export type InfraAdapterId = keyof typeof INFRA_ADAPTER_CATALOG;
10
11
  export type InfraCapability =
@@ -86,11 +87,11 @@ export interface InfraDatabaseConfigMap {
86
87
  }
87
88
  export interface InfraObjectStorageConfigMap {
88
89
  readonly supabase: {
89
- readonly buckets?: readonly string[];
90
+ readonly buckets?: SerializableSet;
90
91
  readonly backend?: InfraS3PersistenceTarget;
91
92
  };
92
93
  readonly r2: {
93
- readonly buckets?: readonly string[];
94
+ readonly buckets?: SerializableSet;
94
95
  readonly accountId?: string;
95
96
  readonly credentials?: InfraControlPlaneCredentialRef;
96
97
  };
@@ -108,13 +109,9 @@ export interface InfraAuthConfigMap {
108
109
  export interface InfraAuthzConfigMap {
109
110
  readonly cerbos: {
110
111
  readonly kind: 'RBAC' | 'ABAC';
111
- readonly policies?: readonly InfraAuthzPolicyFile[];
112
+ readonly policies?: Readonly<Record<string, string>>;
112
113
  };
113
114
  }
114
- export interface InfraAuthzPolicyFile {
115
- readonly path: string;
116
- readonly content: string;
117
- }
118
115
  export interface InfraSecretStoreConfigMap {
119
116
  readonly 'supabase-vault': { readonly schema?: string };
120
117
  }
@@ -161,15 +158,22 @@ export interface InfraEnvironmentSpec {
161
158
  readonly authz?: InfraAuthzSpec;
162
159
  readonly secretStore?: InfraSecretStoreSpec;
163
160
  readonly networking?: InfraNetworkingSpec;
164
- readonly workloads?: readonly InfraWorkloadSpec[];
161
+ readonly workloads?: InfraWorkloadRegistry;
165
162
  }
166
163
 
164
+ export type InfraModuleId = string;
165
+
166
+ export interface InfraModuleSpec {
167
+ readonly config?: DataContractValue;
168
+ }
169
+
170
+ export type InfraModuleRegistry = Readonly<Record<InfraModuleId, InfraModuleSpec>>;
171
+
167
172
  export interface InfraManifest {
168
173
  readonly environments: Readonly<
169
174
  Record<'local', InfraEnvironmentSpec> &
170
175
  Partial<Record<Exclude<AppEnvironmentId, 'local'>, InfraEnvironmentSpec>>
171
176
  >;
172
- readonly apis?: ApiDefinitionList;
173
- readonly modules: readonly string[];
174
- readonly modulesConfig?: Readonly<Record<string, unknown>>;
177
+ readonly apis?: ApiDefinitionRegistry;
178
+ readonly modules: InfraModuleRegistry;
175
179
  }
@@ -1,3 +1,4 @@
1
+ import type { SerializableSet } from '../collections';
1
2
  import type { InfraControlPlaneCredentialRef, InfraSecretReference } from './infraSecrets';
2
3
 
3
4
  export interface InfraWorkloadArtifact {
@@ -25,7 +26,6 @@ export type InfraWorkloadValue =
25
26
  };
26
27
 
27
28
  export interface InfraWorkloadPort {
28
- readonly name: string;
29
29
  readonly port: number;
30
30
  readonly protocol?: 'tcp' | 'udp';
31
31
  /** Exact external listener port requested for a public single-replica workload. */
@@ -57,25 +57,26 @@ export interface InfraWorkloadVolumeSpec {
57
57
  readonly retention: 'retain' | 'delete-on-destroy';
58
58
  }
59
59
 
60
- /** Portable config/policy files; secret values are materialized by the runtime. */
61
- export interface InfraWorkloadFileSpec {
62
- readonly path: string;
63
- readonly content: InfraWorkloadValue;
64
- }
60
+ export type InfraWorkloadId = string;
61
+ export type InfraWorkloadPortRegistry = Readonly<Record<string, InfraWorkloadPort>>;
62
+ export type InfraWorkloadFileMap = Readonly<Record<string, InfraWorkloadValue>>;
63
+ export type InfraWorkloadVolumeRegistry = Readonly<Record<string, InfraWorkloadVolumeSpec>>;
65
64
 
66
65
  export interface InfraWorkloadSpec {
67
- readonly id: string;
66
+ readonly id: InfraWorkloadId;
68
67
  readonly artifact: InfraWorkloadArtifact;
69
68
  readonly command?: readonly string[];
70
69
  readonly args?: readonly string[];
71
- readonly ports?: readonly InfraWorkloadPort[];
70
+ readonly ports?: InfraWorkloadPortRegistry;
72
71
  readonly environment?: Readonly<Record<string, InfraWorkloadValue>>;
73
- readonly files?: readonly InfraWorkloadFileSpec[];
72
+ readonly files?: InfraWorkloadFileMap;
74
73
  readonly health?: InfraWorkloadHealthSpec;
75
74
  readonly resources?: InfraWorkloadResourceSpec;
76
- readonly persistence?: readonly InfraWorkloadVolumeSpec[];
75
+ readonly persistence?: InfraWorkloadVolumeRegistry;
77
76
  readonly exposure?: 'internal' | 'public';
78
77
  readonly replicas?: number;
79
78
  /** IDs in the composed desired-state graph; the orchestrator validates missing/cyclic edges. */
80
- readonly dependsOn?: readonly string[];
79
+ readonly dependsOn?: SerializableSet;
81
80
  }
81
+
82
+ export type InfraWorkloadRegistry = Readonly<Record<InfraWorkloadId, InfraWorkloadSpec>>;