@crvouga/atlas 0.16.0 → 1.0.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +52 -8
  3. package/dist/core/src/cli.d.ts +1 -1
  4. package/dist/core/src/cli.js +1 -1
  5. package/dist/reports/src/attachment-files.d.ts +3 -1
  6. package/dist/reports/src/attachment-files.js +14 -4
  7. package/dist/reports/src/evidence-types.d.ts +22 -0
  8. package/dist/reports/src/garbage.d.ts +39 -0
  9. package/dist/reports/src/garbage.js +82 -0
  10. package/dist/reports/src/layer-merge.js +5 -0
  11. package/dist/reports/src/layer-types.d.ts +1 -1
  12. package/dist/reports/src/publish-checks.js +11 -3
  13. package/dist/reports/src/recording-receipt.d.ts +48 -0
  14. package/dist/reports/src/recording-receipt.js +103 -0
  15. package/dist/reports/src/report-cli.d.ts +1 -1
  16. package/dist/reports/src/report-cli.js +20 -2
  17. package/dist/reports/src/report-index.d.ts +6 -0
  18. package/dist/reports/src/report-index.js +14 -0
  19. package/dist/reports/src/report-status.d.ts +2 -0
  20. package/dist/reports/src/report-status.js +17 -1
  21. package/dist/reports/src/reports-explorer-route.d.ts +8 -0
  22. package/dist/reports/src/reports-route.d.ts +8 -0
  23. package/dist/schema/src/api.d.ts +65 -0
  24. package/dist/schema/src/api.js +7 -2
  25. package/dist/schema/src/cli-results.d.ts +107 -2
  26. package/dist/schema/src/cli-results.js +11 -3
  27. package/dist/schema/src/index.d.ts +1 -0
  28. package/dist/schema/src/index.js +1 -0
  29. package/dist/schema/src/layer.d.ts +165 -0
  30. package/dist/schema/src/layer.js +72 -1
  31. package/dist/schema/src/server-config.d.ts +7 -49
  32. package/dist/schema/src/server-config.js +8 -21
  33. package/dist/schema/src/user-state.d.ts +115 -0
  34. package/dist/schema/src/user-state.js +54 -0
  35. package/dist/server/src/app.d.ts +16 -2
  36. package/dist/server/src/app.js +62 -0
  37. package/dist/server/src/config.d.ts +16 -21
  38. package/dist/server/src/config.js +48 -57
  39. package/dist/server/src/dev.d.ts +1 -1
  40. package/dist/server/src/dev.js +20 -7
  41. package/dist/server/src/index.d.ts +5 -1
  42. package/dist/server/src/index.js +5 -1
  43. package/dist/server/src/main.d.ts +23 -7
  44. package/dist/server/src/main.js +72 -26
  45. package/dist/server/src/openapi.js +24 -5
  46. package/dist/server/src/projects.js +89 -14
  47. package/dist/server/src/settings.d.ts +23 -7
  48. package/dist/server/src/settings.js +50 -60
  49. package/dist/server/src/storage.d.ts +68 -0
  50. package/dist/server/src/storage.js +125 -0
  51. package/dist/server/src/user-state.d.ts +31 -0
  52. package/dist/server/src/user-state.js +74 -0
  53. package/docs/agents.md +1 -1
  54. package/docs/deploying.md +38 -39
  55. package/docs/reference/cli.md +17 -7
  56. package/docs/reference/http-api.md +30 -2
  57. package/docs/reference/reports.md +165 -2
  58. package/docs/reference/schema.md +161 -16
  59. package/docs/reference/server.md +263 -41
  60. package/docs/report-backends.md +0 -144
  61. package/docs/report-contract.md +1 -1
  62. package/docs/rich-evidence.md +29 -0
  63. package/docs/runners.md +1 -1
  64. package/docs/server.md +141 -36
  65. package/docs/statecharts.md +1 -1
  66. package/docs/ui.md +1 -1
  67. package/docs/workspaces.md +5 -2
  68. package/llms-full.txt +265 -234
  69. package/llms.txt +1 -1
  70. package/package.json +2 -2
  71. package/packages/reports/STORAGE.md +1 -1
  72. package/packages/schema/schemas/check-detail.schema.json +20 -0
  73. package/packages/schema/schemas/effective-delta.schema.json +61 -0
  74. package/packages/schema/schemas/effective-evidence.schema.json +61 -0
  75. package/packages/schema/schemas/evidence-page.schema.json +45 -0
  76. package/packages/schema/schemas/gc-policy.schema.json +36 -0
  77. package/packages/schema/schemas/gc-run-result.schema.json +106 -0
  78. package/packages/schema/schemas/gc-snapshot.schema.json +122 -0
  79. package/packages/schema/schemas/layer-rows.schema.json +16 -0
  80. package/packages/schema/schemas/layer.schema.json +153 -0
  81. package/packages/schema/schemas/publish-checks.schema.json +24 -0
  82. package/packages/schema/schemas/publish-result.schema.json +161 -1
  83. package/packages/schema/schemas/report-status.schema.json +104 -0
  84. package/packages/schema/schemas/server-config.schema.json +0 -41
  85. package/packages/schema/schemas/server-editable-settings.schema.json +0 -41
  86. package/packages/schema/schemas/server-settings-snapshot.schema.json +0 -82
  87. package/packages/schema/schemas/user-state.schema.json +140 -0
  88. package/packages/server/README.md +11 -6
  89. package/packages/server/openapi.json +843 -128
  90. package/packages/visualizer/src/admin/SettingsPage.tsx +183 -88
  91. package/packages/visualizer/src/evidence/marks.tsx +16 -0
@@ -15,7 +15,7 @@
15
15
  import { z } from 'zod/v4';
16
16
  import { ProjectsSchema } from '../../schema/src/project.js';
17
17
  import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema, TestCatalogSchema, CatalogHealthSchema, TestHistorySchema, RunListSchema, RunDetailSchema, ArtifactTagEditSchema, ArtifactTagEntrySchema, ArtifactTagsSchema } from '../../schema/src/api.js';
18
- import { LiveRunSchema, LiveRunStartSchema, LiveRunUpdateSchema, ServerEditableSettingsSchema, ServerSettingsSnapshotSchema, AtlasChangeSchema, PublishChecksSchema, ReportSourcesSchema, ReportStatusSchema, RunsIndexSchema, SpecIndexSchema } from '../../schema/src/index.js';
18
+ import { LiveRunSchema, LiveRunStartSchema, LiveRunUpdateSchema, ServerEditableSettingsSchema, ServerSettingsSnapshotSchema, GcPolicySchema, GcSnapshotSchema, GcRunResultSchema, AtlasChangeSchema, PublishChecksSchema, ReportSourcesSchema, ReportStatusSchema, RunsIndexSchema, SpecIndexSchema } from '../../schema/src/index.js';
19
19
  /** The version of the API described, `major.minor.patch`: the minor grows when an operation or field is added, the major never within `/api`. */
20
20
  export const API_VERSION = '1.5.0';
21
21
  /** The JSON Schema of a Zod schema, as OpenAPI 3.1 embeds it. `output` for what the server sends, `input` for what it accepts. */
@@ -30,6 +30,7 @@ function schemaOf(schema, io = 'output') {
30
30
  const COMPONENTS = {
31
31
  LiveRun: [LiveRunSchema, 'output'], LiveRunStart: [LiveRunStartSchema, 'input'], LiveRunUpdate: [LiveRunUpdateSchema, 'input'],
32
32
  ServerEditableSettings: [ServerEditableSettingsSchema, 'input'], ServerSettingsSnapshot: [ServerSettingsSnapshotSchema, 'output'],
33
+ GcPolicy: [GcPolicySchema, 'input'], GcSnapshot: [GcSnapshotSchema, 'output'], GcRunResult: [GcRunResultSchema, 'output'],
33
34
  Problem: [ProblemSchema, 'output'], Health: [HealthSchema, 'output'], Ready: [ReadySchema, 'output'], ServerInfo: [ServerInfoSchema, 'output'],
34
35
  EffectiveEvidence: [EffectiveEvidenceSchema, 'output'], EffectiveDelta: [EffectiveDeltaSchema, 'output'], ReportStatus: [ReportStatusSchema, 'output'],
35
36
  PublishChecks: [PublishChecksSchema.partial({ recordedAtMs: true }), 'input'], ChecksResult: [ChecksResultSchema, 'output'],
@@ -92,16 +93,34 @@ const BASE_OPERATIONS = [
92
93
  },
93
94
  {
94
95
  route: 'GET /api/settings', path: '/api/settings', operationId: 'getSettings', tag: 'Operations', role: 'admin',
95
- summary: 'Read server settings', description: 'Returns editable persisted storage/report settings, active settings, a revision, and configuration precedence. Authentication settings are excluded. Requires an admin Bearer token. Embedded applications may manage settings outside this API.',
96
+ summary: 'Read server settings', description: 'Returns the editable report and live-run settings saved in the user state in S3, active settings, a revision, and configuration precedence. Storage and authentication settings are excluded. Requires an admin Bearer token. Embedded applications may manage settings outside this API.',
96
97
  responses: { '200': json('Settings and revision.', 'ServerSettingsSnapshot'), '401': UNAUTHORIZED, '403': FORBIDDEN, '503': problem('Configuration is managed by the embedding application.') }
97
98
  },
98
99
  {
99
100
  route: 'PUT /api/settings', path: '/api/settings', operationId: 'saveSettings', tag: 'Operations', role: 'admin',
100
- summary: 'Save settings for the next restart', description: 'Replaces editable storage/report settings, preserving authentication and other file fields. Atomically persists a validated configuration; no report data is moved. Environment variables and flags override these values after restart. Requires an admin Bearer token, same-origin requests in browsers, and a current revision.',
101
+ summary: 'Save settings for the next restart', description: 'Replaces the editable report and live-run settings in the user state in S3 (`server/state.json`), preserving the garbage collection policy and other fields. Persists a validated configuration; no report data is moved. Environment variables and flags override these values after restart. Requires an admin Bearer token, same-origin requests in browsers, and a current revision.',
101
102
  parameters: [{ name: 'If-Match', in: 'header', required: true, description: 'The quoted revision returned by GET /api/settings.', schema: str }],
102
- body: { description: 'Editable fields from ServerConfig, excluding authentication, host, port and site. Maximum 64 KiB.', contentType: 'application/json', required: true, schema: ref('ServerEditableSettings') },
103
+ body: { description: 'Editable fields from ServerConfig, excluding storage, authentication, host, port and site. Maximum 64 KiB.', contentType: 'application/json', required: true, schema: ref('ServerEditableSettings') },
103
104
  responses: { '200': json('Saved settings and new revision. Restart required.', 'ServerSettingsSnapshot'), '401': UNAUTHORIZED, '403': FORBIDDEN, '409': problem('Settings changed; reload before saving.'), '413': problem('Settings exceed the body limit.'), '422': problem('Invalid configuration.'), '428': problem('A quoted settings revision is required.'), '503': problem('Configuration is managed by the embedding application.') }
104
105
  },
106
+ {
107
+ route: 'GET /api/settings/gc', path: '/api/settings/gc', operationId: 'getGarbageCollectionPolicy', tag: 'Operations', role: 'admin',
108
+ summary: 'Read the garbage collection policy', description: 'Returns the policy kept in the user state in S3 (`server/state.json`), or `policy: null, saved: false` when none has been saved yet, in which case garbage collection is off and nothing is deleted. Also returns the revision to send back when saving and the last pass over each project. Requires an admin Bearer token. If the stored user state is unreadable the answer is 503 and the stored object is left untouched.',
109
+ responses: { '200': json('The policy, its revision and the last passes.', 'GcSnapshot'), '401': UNAUTHORIZED, '403': FORBIDDEN, '503': problem('The user state in S3 cannot be read, or settings are managed by the embedding application.') }
110
+ },
111
+ {
112
+ route: 'PUT /api/settings/gc', path: '/api/settings/gc', operationId: 'saveGarbageCollectionPolicy', tag: 'Operations', role: 'admin',
113
+ summary: 'Save the garbage collection policy', description: 'Replaces the policy in the user state in S3 and leaves the rest of that document alone. The server reads the policy from S3 each time it collects, so the new policy applies at the next pass, without a restart. Requires an admin Bearer token, same-origin requests in browsers, and the current revision.',
114
+ parameters: [{ name: 'If-Match', in: 'header', required: true, description: 'The quoted revision returned by GET /api/settings/gc.', schema: str }],
115
+ body: { description: 'The policy. Maximum 16 KiB.', contentType: 'application/json', required: true, schema: ref('GcPolicy') },
116
+ responses: { '200': json('The saved policy and the new revision.', 'GcSnapshot'), '401': UNAUTHORIZED, '403': FORBIDDEN, '409': problem('The state changed; reload before saving.'), '413': problem('The body exceeds the limit.'), '422': problem('Invalid policy.'), '428': problem('A quoted revision is required.'), '503': problem('The user state in S3 cannot be read, or settings are managed by the embedding application.') }
117
+ },
118
+ {
119
+ route: 'POST /api/settings/gc/run', path: '/api/settings/gc/run', operationId: 'runGarbageCollection', tag: 'Operations', role: 'admin',
120
+ summary: 'Collect garbage now, or preview it', description: 'Applies the policy saved in S3 to every project now, instead of waiting for the schedule. Only report layers that were read and no longer decide any result, older than the policy allows and beyond its `keepLatest`, are deleted, with the captured files only they used. With `dryRun=1` nothing is deleted and each run reports what it would delete (a disabled policy is previewed as if enabled). Answers 409 when no policy has been saved, and, without `dryRun`, when the saved policy is disabled. Requires an admin Bearer token and a same-origin request in browsers.',
121
+ parameters: [{ name: 'dryRun', in: 'query', required: false, description: '`1` or `true` to only count what would be deleted.', schema: str }],
122
+ responses: { '200': json('One run per project.', 'GcRunResult'), '401': UNAUTHORIZED, '403': FORBIDDEN, '409': problem('No policy has been saved, or it is disabled.') }
123
+ },
105
124
  {
106
125
  route: 'GET /healthz', path: '/healthz', operationId: 'getHealth', tag: 'Operations', role: 'none',
107
126
  summary: 'Is the process up',
@@ -437,7 +456,7 @@ const BASE_OPERATIONS = [
437
456
  responses: { '200': { description: 'The file.', contentType: 'application/octet-stream', schema: { type: 'string', format: 'binary' } }, '401': UNAUTHORIZED, '404': problem('No such run or file.') }
438
457
  }
439
458
  ];
440
- const NODE_OPERATIONS = new Set(['/api/projects', '/api/settings', '/api/server', '/api/openapi.json']);
459
+ const NODE_OPERATIONS = new Set(['/api/projects', '/api/settings', '/api/settings/gc', '/api/settings/gc/run', '/api/server', '/api/openapi.json']);
441
460
  const PROJECT_PARAMETER = { name: 'project', in: 'query', description: 'Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.', schema: { type: 'string', pattern: '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', maxLength: 64 }, example: 'todo-app' };
442
461
  /** Every node operation and the same stable report protocol inside each project namespace. */
443
462
  export const OPERATIONS = BASE_OPERATIONS.flatMap(operation => {
@@ -9,7 +9,7 @@ import { createAuth } from './auth.js';
9
9
  import { problemResponse } from './problem.js';
10
10
  const REGISTRY = 'server/projects/';
11
11
  const SCOPED = /^\/api\/projects\/([^/]+)(\/.*)$/;
12
- const GLOBAL = new Set(['/api/settings', '/api/projects', '/api/openapi.json', '/api/server']);
12
+ const GLOBAL = new Set(['/api/settings', '/api/settings/gc', '/api/settings/gc/run', '/api/projects', '/api/openapi.json', '/api/server']);
13
13
  const scopeStore = (store, slug) => typeof store.forProject === 'function'
14
14
  ? store.forProject(slug) : new ObjectStoreWithPrefix(store, `projects/${slug}`);
15
15
  /** Wrap the single-project protocol in project selection. Auth, upload validation and composition stay identical. */
@@ -61,7 +61,71 @@ export function projectServer(options, create) {
61
61
  };
62
62
  const timer = setInterval(expire, 60_000);
63
63
  timer.unref?.();
64
- const routes = [...base.routes, { method: 'GET', path: '/api/projects' }];
64
+ /** The application of one named project, created on first use over a store scoped to it. `null` when too many are active. */
65
+ const ensureChild = (slug) => {
66
+ let child = children.get(slug);
67
+ if (child)
68
+ return child;
69
+ expire();
70
+ if (children.size >= 64)
71
+ return null;
72
+ child = { touched: Date.now(), pending: 0, server: create({ ...options,
73
+ ...(options.log ? { log: line => options.log({ ...line, project: slug }) } : {}),
74
+ backends: options.backends.map(backend => ({ ...backend, store: scopeStore(backend.store, slug) })),
75
+ ...(options.liveStore ? { liveStore: scopeStore(options.liveStore, slug) } : {}), site: undefined, settings: undefined, gc: undefined }) };
76
+ children.set(slug, child);
77
+ return child;
78
+ };
79
+ // Garbage collection. The policy is read from S3 every time, never cached, so a policy saved in the UI is the one applied.
80
+ const clock = options.now ?? Date.now;
81
+ let collecting;
82
+ let lastCollectedAt = 0;
83
+ const collectAll = (policy, dryRun) => {
84
+ collecting ??= (async () => {
85
+ const runs = [];
86
+ await discover().catch(() => { });
87
+ for (const slug of ['default', ...[...known].filter(item => item !== 'default').sort()]) {
88
+ if (closed)
89
+ break;
90
+ if (slug === 'default') {
91
+ runs.push(await base.collectGarbage(policy, { dryRun, project: slug }));
92
+ continue;
93
+ }
94
+ const child = ensureChild(slug);
95
+ if (!child) {
96
+ runs.push({ project: slug, dryRun, at: new Date(clock()).toISOString(), candidates: 0, deletedLayers: 0, deletedBlobs: 0, keptLayers: 0, error: 'Too many active projects; this one was skipped.' });
97
+ continue;
98
+ }
99
+ child.pending++;
100
+ try {
101
+ await child.server.ready;
102
+ runs.push(await child.server.collectGarbage(policy, { dryRun, project: slug }));
103
+ }
104
+ finally {
105
+ child.pending--;
106
+ child.touched = Date.now();
107
+ }
108
+ }
109
+ if (!dryRun)
110
+ options.gc?.record(runs);
111
+ return runs;
112
+ })().finally(() => { collecting = undefined; });
113
+ return collecting;
114
+ };
115
+ const gcTimer = options.gc ? setInterval(() => {
116
+ void (async () => {
117
+ if (closed || collecting)
118
+ return;
119
+ const policy = await options.gc.policy();
120
+ if (!policy?.enabled || clock() - lastCollectedAt < policy.intervalHours * 3_600_000)
121
+ return;
122
+ await base.ready;
123
+ lastCollectedAt = clock();
124
+ await collectAll(policy, false);
125
+ })().catch(error => options.log?.({ level: 'error', gc: 'schedule', error: String(error) }));
126
+ }, options.gcCheckMs ?? 60_000) : undefined;
127
+ gcTimer?.unref?.();
128
+ const routes = [...base.routes, { method: 'GET', path: '/api/projects' }, { method: 'POST', path: '/api/settings/gc/run' }];
65
129
  for (const route of base.routes)
66
130
  if (route.path.startsWith('/api/') && route.path !== '/api/*' && !GLOBAL.has(route.path))
67
131
  routes.push({ method: route.method, path: route.path.replace('/api/', '/api/projects/:project/') });
@@ -71,6 +135,24 @@ export function projectServer(options, create) {
71
135
  if (closed)
72
136
  return problemResponse(503, 'Atlas is stopping.');
73
137
  const url = new URL(request.url);
138
+ if (url.pathname === '/api/settings/gc/run' && request.method === 'POST') {
139
+ const verdict = auth.check(request, 'admin');
140
+ if (!verdict.ok)
141
+ return problemResponse(verdict.status, verdict.message, verdict.status === 401 ? { 'www-authenticate': 'Basic realm="Atlas"' } : {});
142
+ const origin = request.headers.get('origin');
143
+ const site = request.headers.get('sec-fetch-site');
144
+ if (site === 'cross-site' || (origin && origin !== url.origin && site !== 'same-origin'))
145
+ return problemResponse(403, 'Changes must come from this server origin.');
146
+ if (!options.gc)
147
+ return problemResponse(503, 'Garbage collection settings are managed by the embedding application.');
148
+ const policy = await options.gc.policy();
149
+ if (!policy)
150
+ return problemResponse(409, 'No garbage collection policy has been saved yet, so nothing is deleted. Save one first.');
151
+ const dryRun = ['1', 'true'].includes(url.searchParams.get('dryRun') ?? '');
152
+ if (!dryRun && !policy.enabled)
153
+ return problemResponse(409, 'The saved garbage collection policy is disabled. Enable it, or use dryRun to preview.');
154
+ return Response.json({ runs: await collectAll(dryRun ? { ...policy, enabled: true } : policy, dryRun), policy }, { headers: { 'cache-control': 'no-store' } });
155
+ }
74
156
  if (url.pathname === '/api/projects' && request.method === 'GET') {
75
157
  const verdict = auth.check(request, 'read');
76
158
  if (!verdict.ok)
@@ -116,17 +198,9 @@ export function projectServer(options, create) {
116
198
  return problemResponse(404, 'Unknown project. Publish reports with this project slug first.');
117
199
  }
118
200
  }
119
- let child = children.get(slug);
120
- if (!child) {
121
- expire();
122
- if (children.size >= 64)
123
- return problemResponse(429, 'Too many active projects. Retry when another project is idle.');
124
- child = { touched: Date.now(), pending: 0, server: create({ ...options,
125
- ...(options.log ? { log: line => options.log({ ...line, project: slug }) } : {}),
126
- backends: options.backends.map(backend => ({ ...backend, store: scopeStore(backend.store, slug) })),
127
- ...(options.liveStore ? { liveStore: scopeStore(options.liveStore, slug) } : {}), site: undefined, settings: undefined }) };
128
- children.set(slug, child);
129
- }
201
+ const child = ensureChild(slug);
202
+ if (!child)
203
+ return problemResponse(429, 'Too many active projects. Retry when another project is idle.');
130
204
  child.touched = Date.now();
131
205
  child.pending++;
132
206
  try {
@@ -149,6 +223,7 @@ export function projectServer(options, create) {
149
223
  return problemResponse(503, 'The project backend is unavailable. Retry later.');
150
224
  }
151
225
  },
152
- close() { closed = true; clearInterval(timer); base.close(); for (const child of children.values())
226
+ close() { closed = true; clearInterval(timer); if (gcTimer)
227
+ clearInterval(gcTimer); base.close(); for (const child of children.values())
153
228
  child.server.close(); children.clear(); } };
154
229
  }
@@ -1,5 +1,7 @@
1
+ import { type GcPolicy, type GcRun, type GcSnapshot } from '../../schema/src/user-state.js';
1
2
  import { type ResolvedServerConfig } from './config.js';
2
- /** The persisted editable settings, active settings and a revision for optimistic concurrency. Authentication token settings are excluded. */
3
+ import { type UserStateStore } from './user-state.js';
4
+ /** The saved editable settings, the active settings and a revision for optimistic concurrency. Authentication token settings are excluded. */
3
5
  export type SettingsSnapshot = {
4
6
  readonly revision: string;
5
7
  readonly settings: Record<string, unknown>;
@@ -7,17 +9,31 @@ export type SettingsSnapshot = {
7
9
  readonly restartRequired: boolean;
8
10
  readonly precedence: string;
9
11
  };
10
- /** Storage for the admin API; saving is serialized and refuses a stale revision. */
12
+ /** Storage for the admin API; saving is serialised and refuses a stale revision. */
11
13
  export type ServerSettings = {
12
14
  read(): Promise<SettingsSnapshot>;
13
15
  save(settings: unknown, revision: string): Promise<SettingsSnapshot>;
14
16
  };
17
+ /** The garbage collection policy as the admin API and the collector see it. */
18
+ export type GcSettings = {
19
+ /** The policy in S3 with its revision and the last passes. */
20
+ read(): Promise<GcSnapshot>;
21
+ /** Validates and saves a policy; a stale `revision` is refused. */
22
+ save(policy: unknown, revision: string): Promise<GcSnapshot>;
23
+ /** The policy in S3 right now, or `null` when none has been saved. Read from S3 every time. */
24
+ policy(): Promise<GcPolicy | null>;
25
+ /** Remembers the result of a pass, for `read`. */
26
+ record(runs: readonly GcRun[]): void;
27
+ };
15
28
  /**
16
- * Manage the server's configuration file with atomic replacement, preserving authentication and unrelated fields.
17
- * Validates both the file alone and the effective configuration before saving. Never migrates or deletes report data.
18
- * Changes need a server restart; deployment environment variables and flags override the file.
29
+ * The settings and garbage collection policy kept in `userState` (S3). Validates a settings change against the
30
+ * whole deployment (file, environment and flags) before saving, so what is saved can start. Never moves report data.
31
+ *
19
32
  * @example
20
- * const settings = fileServerSettings(config, argv, process.env, process.cwd());
33
+ * const { settings, gc } = storedSettings(config, argv, process.env, process.cwd(), userStateStore(store));
21
34
  */
22
- export declare function fileServerSettings(config: ResolvedServerConfig, argv: readonly string[], env: Readonly<Record<string, string | undefined>>, cwd: string): ServerSettings;
35
+ export declare function storedSettings(config: ResolvedServerConfig, argv: readonly string[], env: Readonly<Record<string, string | undefined>>, cwd: string, userState: UserStateStore): {
36
+ settings: ServerSettings;
37
+ gc: GcSettings;
38
+ };
23
39
  export { SETTINGS_PAGE, renderSettingsPage } from './settings-page.js';
@@ -1,72 +1,62 @@
1
1
  /** @module Persisted server settings
2
- * The admin UI edits storage and report configuration in atlas.server.json. Changes take effect on restart;
3
- * environment variables and flags keep their documented precedence. Authentication is managed outside this UI.
2
+ * The settings page edits two things, and both are kept in the user state document in S3 (`server/state.json`), never on
3
+ * the server's disk: the report and live-run settings (applied at the next start, below the configuration file,
4
+ * the environment and the flags in precedence) and the garbage collection policy (read by the server each time it
5
+ * collects). Storage and authentication are deployment-owned and are not editable here.
4
6
  */
5
- import { createHash, randomUUID } from 'node:crypto';
6
- import { readFile, rename, rm, writeFile } from 'node:fs/promises';
7
- import path from 'node:path';
8
7
  import { ServerEditableSettingsSchema } from '../../schema/src/server-config.js';
9
- import { parseStoreLocation } from '../../reports/src/store-url.js';
8
+ import { GcPolicySchema } from '../../schema/src/user-state.js';
10
9
  import { ConfigError, loadServerConfig } from './config.js';
10
+ import { USER_STATE_KEY, UserStateError } from './user-state.js';
11
11
  const editable = ServerEditableSettingsSchema;
12
- const keys = new Set(Object.keys(editable.shape));
13
- const select = (value) => Object.fromEntries(Object.entries(value).filter(([key]) => keys.has(key)));
14
- const digest = (text) => createHash('sha256').update(text).digest('hex');
12
+ const conflict = (error) => {
13
+ if (error instanceof UserStateError)
14
+ throw new ConfigError(error.message);
15
+ throw error;
16
+ };
15
17
  /**
16
- * Manage the server's configuration file with atomic replacement, preserving authentication and unrelated fields.
17
- * Validates both the file alone and the effective configuration before saving. Never migrates or deletes report data.
18
- * Changes need a server restart; deployment environment variables and flags override the file.
18
+ * The settings and garbage collection policy kept in `userState` (S3). Validates a settings change against the
19
+ * whole deployment (file, environment and flags) before saving, so what is saved can start. Never moves report data.
20
+ *
19
21
  * @example
20
- * const settings = fileServerSettings(config, argv, process.env, process.cwd());
22
+ * const { settings, gc } = storedSettings(config, argv, process.env, process.cwd(), userStateStore(store));
21
23
  */
22
- export function fileServerSettings(config, argv, env, cwd) {
23
- const file = config.configFile ?? path.join(cwd, 'atlas.server.json');
24
- const active = { data: config.data, backends: config.backends.slice(1), write: config.writeId, outlineBranch: config.outlineBranch, include: config.include, refreshSeconds: config.refreshSeconds, limits: config.limits, heartbeatSeconds: config.heartbeatSeconds, runTimeoutSeconds: config.runTimeoutSeconds, runStallSeconds: config.runStallSeconds, log: config.log };
24
+ export function storedSettings(config, argv, env, cwd, userState) {
25
+ const active = { outlineBranch: config.outlineBranch, include: config.include, refreshSeconds: config.refreshSeconds, limits: config.limits, heartbeatSeconds: config.heartbeatSeconds, runTimeoutSeconds: config.runTimeoutSeconds, runStallSeconds: config.runStallSeconds, log: config.log };
25
26
  let saved = false;
26
- let pending = Promise.resolve();
27
- const readText = () => readFile(file, 'utf8').catch(error => { if (error.code === 'ENOENT')
28
- return '{}'; throw error; });
29
- const read = async () => {
30
- const text = await readText();
31
- return { revision: digest(text), settings: select(JSON.parse(text)), active,
32
- restartRequired: saved, precedence: 'File settings apply after restart. Environment variables and command-line flags override the file.' };
27
+ let lastRuns = [];
28
+ const snapshot = (stored) => ({
29
+ revision: stored.revision, settings: (stored.state.settings ?? {}), active, restartRequired: saved,
30
+ precedence: `Saved in S3 (${USER_STATE_KEY}) and applied after restart. The configuration file, environment variables and command-line flags override them.`
31
+ });
32
+ const gcSnapshot = (stored) => ({
33
+ revision: stored.revision, policy: stored.state.gc ? GcPolicySchema.parse(stored.state.gc) : null, saved: stored.state.gc !== undefined, lastRuns, stateKey: USER_STATE_KEY
34
+ });
35
+ const settings = {
36
+ read: async () => snapshot(await userState.read().catch(conflict)),
37
+ async save(input, revision) {
38
+ const parsed = editable.safeParse(input);
39
+ if (!parsed.success)
40
+ throw new ConfigError(parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; '));
41
+ // Prove the exact deployment (file, environment, flags and these settings) can start before keeping them.
42
+ loadServerConfig(argv, env, cwd, undefined, parsed.data);
43
+ // What the person chose, as sent: validated, but without the defaults filled in, so unchosen settings keep following the defaults.
44
+ const stored = await userState.update(revision, state => ({ ...state, settings: input })).catch(conflict);
45
+ saved = true;
46
+ return snapshot(stored);
47
+ }
33
48
  };
34
- return { read, save(settings, revision) {
35
- const update = pending.then(async () => {
36
- const parsed = editable.safeParse(settings);
37
- if (!parsed.success)
38
- throw new ConfigError(parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; '));
39
- const before = await readText();
40
- if (digest(before) !== revision)
41
- throw new ConfigError('Settings changed. Reload them before saving.');
42
- const original = JSON.parse(before);
43
- const merged = { ...Object.fromEntries(Object.entries(original).filter(([key]) => !keys.has(key))), ...parsed.data };
44
- const text = `${JSON.stringify(merged, null, 2)}\n`;
45
- // Prove it is independently usable, then prove the exact deployment (including overrides) can start.
46
- const base = loadServerConfig(['--config', file], env, cwd, () => text);
47
- const effective = loadServerConfig([...argv, '--config', file], env, cwd, () => text);
48
- try {
49
- for (const backend of [...base.backends, ...effective.backends])
50
- parseStoreLocation(backend.url, path.dirname(file), env);
51
- }
52
- catch (error) {
53
- throw new ConfigError(error instanceof Error ? error.message : String(error));
54
- }
55
- const temporary = `${file}.${randomUUID()}.tmp`;
56
- try {
57
- await writeFile(temporary, text, { mode: 0o600, flag: 'wx' });
58
- if (await readText() !== before)
59
- throw new ConfigError('Settings changed. Reload them before saving.');
60
- await rename(temporary, file);
61
- }
62
- finally {
63
- await rm(temporary, { force: true });
64
- }
65
- saved = true;
66
- return read();
67
- });
68
- pending = update.catch(() => undefined);
69
- return update;
70
- } };
49
+ const gc = {
50
+ read: async () => gcSnapshot(await userState.read().catch(conflict)),
51
+ async save(input, revision) {
52
+ const parsed = GcPolicySchema.safeParse(input);
53
+ if (!parsed.success)
54
+ throw new ConfigError(parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; '));
55
+ return gcSnapshot(await userState.update(revision, state => ({ ...state, gc: parsed.data })).catch(conflict));
56
+ },
57
+ policy: async () => { const { state } = await userState.read(); return state.gc ? GcPolicySchema.parse(state.gc) : null; },
58
+ record(runs) { lastRuns = [...runs, ...lastRuns.filter(old => !runs.some(run => run.project === old.project))].slice(0, 50); }
59
+ };
60
+ return { settings, gc };
71
61
  }
72
62
  export { SETTINGS_PAGE, renderSettingsPage } from './settings-page.js';
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @module Required S3 storage configuration
3
+ *
4
+ * The Atlas server keeps ALL reports and ALL user state (the garbage collection policy and saved settings) in
5
+ * S3-compatible object storage, and nowhere else: no local folder, no memory, no database. So the storage settings
6
+ * are required, come only from environment variables (they hold secrets), and have no defaults for where or as whom
7
+ * to connect. `resolveS3Config` checks every variable in one pass and reports all the problems together;
8
+ * `startupFailure` renders them as a banner that cannot be missed. `S3_VARIABLES` is the single description of the
9
+ * variables: the banner, `.env.example` and the documentation table are all made from it.
10
+ *
11
+ * @see docs/server.md#required-s3-storage
12
+ */
13
+ type Env = Readonly<Record<string, string | undefined>>;
14
+ /** One environment variable of the storage configuration. */
15
+ export type S3Variable = {
16
+ readonly name: string;
17
+ readonly required: boolean;
18
+ /** Shown in the banner and the documentation. Never a real secret. */
19
+ readonly meaning: string;
20
+ readonly example: string;
21
+ /** Secrets are never printed, not even when invalid. */
22
+ readonly secret?: true;
23
+ /** Returns what is wrong with a value that is set, or null when it is fine. */
24
+ readonly check: (value: string) => string | null;
25
+ };
26
+ /** Every variable that configures storage, required ones first. */
27
+ export declare const S3_VARIABLES: readonly S3Variable[];
28
+ /** The storage settings once checked: everything needed to open the bucket, with credentials spelled out. */
29
+ export type S3Config = {
30
+ readonly endpoint: string;
31
+ readonly bucket: string;
32
+ readonly region: string;
33
+ readonly prefix: string;
34
+ readonly pathStyle: boolean;
35
+ readonly accessKeyId: string;
36
+ readonly secretAccessKey: string;
37
+ readonly sessionToken?: string;
38
+ };
39
+ /** The state of one variable after checking, for the banner. */
40
+ export type S3Finding = {
41
+ readonly variable: S3Variable;
42
+ readonly status: 'ok' | 'missing' | 'invalid' | 'unset';
43
+ readonly problem?: string;
44
+ };
45
+ /** The storage configuration could not be used. `findings` has every variable, not only the first problem. */
46
+ export declare class S3ConfigError extends Error {
47
+ readonly findings: readonly S3Finding[];
48
+ readonly reason: string;
49
+ constructor(findings: readonly S3Finding[], reason?: string);
50
+ }
51
+ /** Looks at every storage variable and returns what each is. Nothing here throws. */
52
+ export declare function inspectS3Env(env: Env): readonly S3Finding[];
53
+ /**
54
+ * The storage settings from the environment. Throws {@link S3ConfigError}, whose message lists every variable and
55
+ * marks each missing or invalid one, when any required variable is absent or any set variable is malformed.
56
+ *
57
+ * @example
58
+ * const s3 = resolveS3Config(process.env);
59
+ */
60
+ export declare function resolveS3Config(env: Env): S3Config;
61
+ /** Where the storage is, without a secret: what the log may say. */
62
+ export declare const describeS3: (config: S3Config) => string;
63
+ /**
64
+ * The banner printed when the server cannot start because of storage: why storage is required, then EVERY variable by
65
+ * name with what is wrong with it. Values of secrets are never printed.
66
+ */
67
+ export declare function startupFailure(findings: readonly S3Finding[], reason: string, detail?: string): string;
68
+ export {};
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @module Required S3 storage configuration
3
+ *
4
+ * The Atlas server keeps ALL reports and ALL user state (the garbage collection policy and saved settings) in
5
+ * S3-compatible object storage, and nowhere else: no local folder, no memory, no database. So the storage settings
6
+ * are required, come only from environment variables (they hold secrets), and have no defaults for where or as whom
7
+ * to connect. `resolveS3Config` checks every variable in one pass and reports all the problems together;
8
+ * `startupFailure` renders them as a banner that cannot be missed. `S3_VARIABLES` is the single description of the
9
+ * variables: the banner, `.env.example` and the documentation table are all made from it.
10
+ *
11
+ * @see docs/server.md#required-s3-storage
12
+ */
13
+ const hostOf = (value) => {
14
+ try {
15
+ const url = new URL(value);
16
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
17
+ return 'must start with https:// (or http:// for a local S3 server)';
18
+ if (url.username || url.password)
19
+ return 'must not contain a user name or password; credentials have their own variables';
20
+ if (url.search || url.hash)
21
+ return 'must be just the address, with no query or fragment';
22
+ return null;
23
+ }
24
+ catch {
25
+ return 'is not a URL; write it like https://s3.eu-west-1.amazonaws.com';
26
+ }
27
+ };
28
+ /** Every variable that configures storage, required ones first. */
29
+ export const S3_VARIABLES = [
30
+ { name: 'ATLAS_S3_ENDPOINT', required: true, meaning: 'S3 API endpoint of the object storage service', example: 'https://s3.eu-west-1.amazonaws.com', check: hostOf },
31
+ { name: 'ATLAS_S3_BUCKET', required: true, meaning: 'Bucket that holds reports and user state; it must already exist', example: 'my-atlas-reports',
32
+ check: value => (/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value) ? null : 'must be 3-63 lowercase letters, digits, dots or hyphens, starting and ending with a letter or digit') },
33
+ { name: 'ATLAS_S3_REGION', required: true, meaning: 'Region of the bucket (`auto` for Cloudflare R2, `us-east-1` for most self-hosted servers)', example: 'eu-west-1',
34
+ check: value => (/^[a-z0-9][a-z0-9-]*$/.test(value) ? null : 'must be lowercase letters, digits and hyphens, like eu-west-1') },
35
+ { name: 'ATLAS_S3_ACCESS_KEY_ID', required: true, meaning: 'Access key id of an account that may list, read, write and delete in the bucket', example: 'AKIAEXAMPLE', secret: true,
36
+ check: value => (/^\S+$/.test(value) ? null : 'must not contain spaces or line breaks') },
37
+ { name: 'ATLAS_S3_SECRET_ACCESS_KEY', required: true, meaning: 'Secret key that goes with the access key id', example: 'change-me', secret: true,
38
+ check: value => (/^\S+$/.test(value) ? null : 'must not contain spaces or line breaks') },
39
+ { name: 'ATLAS_S3_PREFIX', required: false, meaning: 'Folder inside the bucket for everything Atlas writes (default: the bucket root)', example: 'atlas',
40
+ check: value => (/^[\w./-]+$/.test(value) && !value.split('/').includes('..') ? null : 'may contain only letters, digits, `.`, `_`, `-` and `/`, and no `..`') },
41
+ { name: 'ATLAS_S3_PATH_STYLE', required: false, meaning: 'Address the bucket as endpoint/bucket/key (default: yes; set 0 for virtual-hosted style)', example: '1',
42
+ check: value => (['1', '0', 'true', 'false'].includes(value) ? null : 'must be 1, 0, true or false') },
43
+ { name: 'ATLAS_S3_SESSION_TOKEN', required: false, meaning: 'Session token, only for temporary credentials', example: '', secret: true,
44
+ check: value => (/^\S+$/.test(value) ? null : 'must not contain spaces or line breaks') }
45
+ ];
46
+ /** The storage configuration could not be used. `findings` has every variable, not only the first problem. */
47
+ export class S3ConfigError extends Error {
48
+ findings;
49
+ reason;
50
+ constructor(findings, reason = 'the S3 configuration is missing or invalid') {
51
+ super(startupFailure(findings, reason));
52
+ this.findings = findings;
53
+ this.reason = reason;
54
+ this.name = 'S3ConfigError';
55
+ }
56
+ }
57
+ /** Looks at every storage variable and returns what each is. Nothing here throws. */
58
+ export function inspectS3Env(env) {
59
+ return S3_VARIABLES.map(variable => {
60
+ const raw = env[variable.name];
61
+ if (raw === undefined || raw.trim() === '')
62
+ return { variable, status: variable.required ? 'missing' : 'unset' };
63
+ const problem = variable.check(raw.trim());
64
+ return problem ? { variable, status: 'invalid', problem } : { variable, status: 'ok' };
65
+ });
66
+ }
67
+ /**
68
+ * The storage settings from the environment. Throws {@link S3ConfigError}, whose message lists every variable and
69
+ * marks each missing or invalid one, when any required variable is absent or any set variable is malformed.
70
+ *
71
+ * @example
72
+ * const s3 = resolveS3Config(process.env);
73
+ */
74
+ export function resolveS3Config(env) {
75
+ const findings = inspectS3Env(env);
76
+ if (findings.some(finding => finding.status === 'missing' || finding.status === 'invalid'))
77
+ throw new S3ConfigError(findings);
78
+ const value = (name) => env[name]?.trim() ?? '';
79
+ const style = value('ATLAS_S3_PATH_STYLE');
80
+ return {
81
+ endpoint: value('ATLAS_S3_ENDPOINT').replace(/\/+$/, ''), bucket: value('ATLAS_S3_BUCKET'), region: value('ATLAS_S3_REGION'),
82
+ prefix: value('ATLAS_S3_PREFIX').replace(/^\/+|\/+$/g, ''), pathStyle: style === '' ? true : style === '1' || style === 'true',
83
+ accessKeyId: value('ATLAS_S3_ACCESS_KEY_ID'), secretAccessKey: value('ATLAS_S3_SECRET_ACCESS_KEY'),
84
+ ...(value('ATLAS_S3_SESSION_TOKEN') ? { sessionToken: value('ATLAS_S3_SESSION_TOKEN') } : {})
85
+ };
86
+ }
87
+ /** Where the storage is, without a secret: what the log may say. */
88
+ export const describeS3 = (config) => `s3://${config.bucket}/${config.prefix ? `${config.prefix}/` : ''} on ${new URL(config.endpoint).host} (${config.region})`;
89
+ const RULE = '='.repeat(78);
90
+ /**
91
+ * The banner printed when the server cannot start because of storage: why storage is required, then EVERY variable by
92
+ * name with what is wrong with it. Values of secrets are never printed.
93
+ */
94
+ export function startupFailure(findings, reason, detail) {
95
+ const width = Math.max(...findings.map(finding => finding.variable.name.length));
96
+ const mark = (finding) => finding.status === 'missing' ? '[MISSING]' : finding.status === 'invalid' ? '[INVALID]' : finding.status === 'ok' ? '[ok] ' : '[not set]';
97
+ const line = (finding) => {
98
+ const { variable } = finding;
99
+ const note = finding.status === 'invalid' ? `${finding.problem}` : variable.meaning;
100
+ return ` ${mark(finding)} ${variable.name.padEnd(width)} ${note}`;
101
+ };
102
+ const required = findings.filter(finding => finding.variable.required);
103
+ const optional = findings.filter(finding => !finding.variable.required);
104
+ const bad = findings.filter(finding => finding.status === 'missing' || finding.status === 'invalid').map(finding => finding.variable.name);
105
+ return [
106
+ '', RULE,
107
+ ' ATLAS CANNOT START: S3 STORAGE IS NOT USABLE',
108
+ RULE,
109
+ ` Why: ${reason}.`,
110
+ ' Reason: Atlas stores ALL reports and ALL user state (the garbage collection policy and saved',
111
+ ' settings) in S3-compatible object storage, and nowhere else. There is no local-disk,',
112
+ ' in-memory or database fallback, so the server refuses to start without working S3.',
113
+ ...(detail ? ['', ` S3 said: ${detail}`] : []),
114
+ '',
115
+ ' Required environment variables:',
116
+ ...required.map(line),
117
+ '',
118
+ ' Optional environment variables:',
119
+ ...optional.map(line),
120
+ '',
121
+ bad.length ? ` Fix these ${bad.length}: ${bad.join(', ')}` : ' Every variable is well-formed, so the values themselves are wrong; check them against the storage service.',
122
+ ' See .env.example for a complete example and docs/server.md#required-s3-storage.',
123
+ ' Exiting with status 2.', RULE, ''
124
+ ].join('\n');
125
+ }
@@ -0,0 +1,31 @@
1
+ import type { ObjectStore } from '../../reports/src/object-store.js';
2
+ import { type UserState } from '../../schema/src/user-state.js';
3
+ /** Where the user state lives, relative to the store (the bucket plus `ATLAS_S3_PREFIX`). */
4
+ export declare const USER_STATE_KEY = "server/state.json";
5
+ /** The user state as stored: the document, its `revision` and whether the object `exists` yet. */
6
+ export type StoredUserState = {
7
+ readonly state: UserState;
8
+ readonly revision: string;
9
+ readonly exists: boolean;
10
+ };
11
+ /** Raised when the stored user state cannot be trusted. The message says where it is and what to do. */
12
+ export declare class UserStateError extends Error {
13
+ }
14
+ /** The user state of one server: read it, or change it against the revision that was read. */
15
+ export type UserStateStore = {
16
+ /** Reads the document. Throws {@link UserStateError} when it exists but is unreadable or invalid. */
17
+ read(): Promise<StoredUserState>;
18
+ /**
19
+ * Applies `change` to the stored state and writes the result, if `revision` is still the stored one. Throws a
20
+ * `UserStateError` starting with "Settings changed" for a stale revision.
21
+ */
22
+ update(revision: string, change: (state: UserState) => UserState): Promise<StoredUserState>;
23
+ };
24
+ /**
25
+ * The user state kept in `store` at {@link USER_STATE_KEY}.
26
+ *
27
+ * @example
28
+ * const userState = userStateStore(s3Backend.store);
29
+ * const { state } = await userState.read();
30
+ */
31
+ export declare function userStateStore(store: ObjectStore): UserStateStore;