@crvouga/atlas 1.12.0 → 1.14.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.
- package/CHANGELOG.md +13 -1
- package/dist/critics/src/contract.d.ts +113 -0
- package/dist/critics/src/contract.js +48 -0
- package/dist/critics/src/credentials.d.ts +72 -0
- package/dist/critics/src/credentials.js +136 -0
- package/dist/critics/src/critique.d.ts +128 -0
- package/dist/critics/src/critique.js +163 -0
- package/dist/critics/src/index.d.ts +21 -0
- package/dist/critics/src/index.js +21 -0
- package/dist/critics/src/judging.d.ts +153 -0
- package/dist/critics/src/judging.js +145 -0
- package/dist/critics/src/publish.d.ts +23 -0
- package/dist/critics/src/publish.js +21 -0
- package/dist/critics/src/runner.d.ts +104 -0
- package/dist/critics/src/runner.js +202 -0
- package/dist/critics/src/scope.d.ts +8 -0
- package/dist/critics/src/scope.js +26 -0
- package/dist/playwright/src/index.d.ts +4 -0
- package/dist/playwright/src/index.js +4 -0
- package/dist/playwright/src/ui-catalog.d.ts +51 -0
- package/dist/playwright/src/ui-catalog.js +44 -0
- package/dist/reports/src/compose.d.ts +2 -2
- package/dist/reports/src/compose.js +6 -5
- package/dist/reports/src/evidence-types.d.ts +3 -0
- package/dist/reports/src/evidence-view.d.ts +2 -1
- package/dist/reports/src/evidence-view.js +5 -2
- package/dist/reports/src/layer-merge.d.ts +16 -4
- package/dist/reports/src/layer-merge.js +23 -5
- package/dist/reports/src/report-index.js +9 -5
- package/dist/reports/src/report-status.d.ts +1 -1
- package/dist/reports/src/report-status.js +6 -4
- package/dist/reports/src/ui-catalog-publish.d.ts +92 -0
- package/dist/reports/src/ui-catalog-publish.js +68 -0
- package/dist/reports/src/ui-catalog.d.ts +229 -0
- package/dist/reports/src/ui-catalog.js +273 -0
- package/dist/reports/src/ui-state.d.ts +70 -0
- package/dist/reports/src/ui-state.js +66 -0
- package/dist/schema/src/api.d.ts +351 -0
- package/dist/schema/src/api.js +72 -1
- package/dist/schema/src/critique.d.ts +144 -0
- package/dist/schema/src/critique.js +96 -0
- package/dist/schema/src/index.d.ts +2 -1
- package/dist/schema/src/index.js +2 -1
- package/dist/schema/src/server-config.d.ts +7 -1
- package/dist/schema/src/server-config.js +6 -3
- package/dist/server/src/app.js +15 -0
- package/dist/server/src/auth.d.ts +1 -1
- package/dist/server/src/auth.js +2 -2
- package/dist/server/src/config.js +2 -2
- package/dist/server/src/openapi.d.ts +3 -3
- package/dist/server/src/openapi.js +29 -6
- package/dist/server/src/store-api.js +39 -1
- package/docs/agents.md +1 -0
- package/docs/critics.md +190 -0
- package/docs/extending.md +1 -0
- package/docs/reference/critics.md +840 -0
- package/docs/reference/http-api.md +52 -2
- package/docs/reference/playwright.md +63 -0
- package/docs/reference/reports.md +608 -8
- package/docs/reference/schema.md +152 -2
- package/docs/reference/server.md +3 -3
- package/docs/report-contract.md +6 -2
- package/docs/rich-evidence.md +5 -0
- package/docs/server.md +8 -4
- package/docs/ui-catalog.md +173 -0
- package/docs/ui.md +15 -0
- package/llms-full.txt +457 -8
- package/llms.txt +3 -0
- package/package.json +8 -2
- package/packages/critics/README.md +18 -0
- package/packages/schema/schemas/critique.schema.json +254 -0
- package/packages/schema/schemas/effective-delta.schema.json +73 -0
- package/packages/schema/schemas/effective-evidence.schema.json +73 -0
- package/packages/schema/schemas/layer-rows.schema.json +73 -0
- package/packages/schema/schemas/server-config.schema.json +2 -1
- package/packages/schema/schemas/server-info.schema.json +1 -0
- package/packages/server/openapi.json +1443 -80
- package/packages/visualizer/src/app/AppShell.tsx +36 -30
- package/packages/visualizer/src/app/router.tsx +10 -1
- package/packages/visualizer/src/catalog/UiCatalogFilters.tsx +65 -0
- package/packages/visualizer/src/catalog/UiCatalogPage.tsx +342 -0
- package/packages/visualizer/src/catalog/provider.tsx +25 -0
- package/packages/visualizer/src/catalog/search.ts +68 -0
- package/packages/visualizer/src/catalog/words.ts +26 -0
- package/packages/visualizer/src/components/Header.tsx +17 -4
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module The `atlas.critique` evidence schema
|
|
3
|
+
*
|
|
4
|
+
* What an automated reviewer (a critic) says about one captured UI. It is not a new layer format: a critique is a
|
|
5
|
+
* check inside a part of kind `critique`, and the opinion rides in the check's `extensions["atlas.critique"]`, so by the
|
|
6
|
+
* rules of docs/extending.md it can never decide a result. The check's `status` says only whether the critic ran
|
|
7
|
+
* (`passed` a verdict was produced, `failed` the critic could not produce one, `running`, `not-run`).
|
|
8
|
+
*
|
|
9
|
+
* Everything except `schema`, `subject` and `critic` is optional, so a single score (a model-based critic) and rich
|
|
10
|
+
* findings (an LLM critic) are one shape. Every object is open, and `kind`, `category`, `severity`, `label` and `reason`
|
|
11
|
+
* are open strings: a value nobody has heard of is kept and shown generically, never rejected. A reader that cannot read
|
|
12
|
+
* the whole extension drops it and keeps the check and its status (`readCritique` in `@crvouga/atlas/critics`).
|
|
13
|
+
*
|
|
14
|
+
* @see docs/critics.md
|
|
15
|
+
* @see docs/extending.md
|
|
16
|
+
*/
|
|
17
|
+
import { z } from 'zod/v4';
|
|
18
|
+
/** The key under which a critique is stored in a check's `extensions`. */
|
|
19
|
+
export const CRITIQUE_EXTENSION = 'atlas.critique';
|
|
20
|
+
/** The version of the `atlas.critique` extension a reader of this release understands. A critique of another version is unknown, not wrong. */
|
|
21
|
+
export const CRITIQUE_SCHEMA_VERSION = 1;
|
|
22
|
+
/** The `kind` of the report part that holds critiques. Parts of this kind are opinions: never counted, never part of the gate. */
|
|
23
|
+
export const CRITIQUE_PART_KIND = 'critique';
|
|
24
|
+
/** The prefix of the `producer` of a layer of critiques: `critic.<critic name>`. */
|
|
25
|
+
export const CRITIC_PRODUCER_PREFIX = 'critic.';
|
|
26
|
+
/** The region of the image a finding is about, in the stated unit (`px` of the stored image, or `normalized` 0..1). */
|
|
27
|
+
export const CritiqueRegionSchema = z.looseObject({
|
|
28
|
+
unit: z.string().min(1).describe('`px` (pixels of the stored image) or `normalized` (0..1 of its width and height). Open vocabulary'),
|
|
29
|
+
x: z.number(),
|
|
30
|
+
y: z.number(),
|
|
31
|
+
w: z.number().nonnegative(),
|
|
32
|
+
h: z.number().nonnegative()
|
|
33
|
+
});
|
|
34
|
+
/** One thing a critic found: where, what kind, how bad, why, and what to do. Nothing is required. */
|
|
35
|
+
export const CritiqueFindingSchema = z.looseObject({
|
|
36
|
+
id: z.string().min(1).optional().describe('Stable within the critique, so a reviewer can mark this finding valid or not'),
|
|
37
|
+
category: z.string().min(1).optional().describe('Open vocabulary: `spacing`, `contrast`, `alignment`, `overflow`, `hierarchy`…'),
|
|
38
|
+
severity: z.string().min(1).optional().describe('Open vocabulary, conventionally `info`, `minor`, `major`'),
|
|
39
|
+
confidence: z.number().min(0).max(1).optional(),
|
|
40
|
+
region: CritiqueRegionSchema.optional(),
|
|
41
|
+
rationale: z.string().optional(),
|
|
42
|
+
suggestion: z.string().optional()
|
|
43
|
+
});
|
|
44
|
+
/**
|
|
45
|
+
* How a score reads in context. A raw score is not absolute: a loading overlay is compared with other loading overlays,
|
|
46
|
+
* not with full screens, and a change against the same entry's previous image is more telling than the level.
|
|
47
|
+
*/
|
|
48
|
+
export const CritiqueRelativeSchema = z.looseObject({
|
|
49
|
+
stateKind: z.string().min(1).describe('The kind of UI state the score is relative to: `screen`, `overlay`, `loading`, `error`, `empty`…'),
|
|
50
|
+
percentile: z.number().min(0).max(100).optional().describe('Where the score falls among the project\'s scores of the same state kind, 0..100, higher is better'),
|
|
51
|
+
sampleSize: z.number().int().nonnegative().optional().describe('How many scores of that state kind the percentile is over'),
|
|
52
|
+
previousScore: z.number().optional().describe('The same entry\'s score for its previous image, from the same critic'),
|
|
53
|
+
delta: z.number().optional().describe('`score` minus `previousScore`')
|
|
54
|
+
});
|
|
55
|
+
/** The judgement: a score and how to read it, a label, a confidence. A critic that cannot score may give only a label. */
|
|
56
|
+
export const CritiqueVerdictSchema = z.looseObject({
|
|
57
|
+
score: z.number().optional().describe('A finite number. `scale` says what it means'),
|
|
58
|
+
scale: z.string().optional().describe('For example `0..1, higher is better`'),
|
|
59
|
+
label: z.string().optional().describe('Open vocabulary, conventionally `ok` or `needs-polish`'),
|
|
60
|
+
confidence: z.number().min(0).max(1).optional(),
|
|
61
|
+
relative: CritiqueRelativeSchema.optional()
|
|
62
|
+
});
|
|
63
|
+
/** Why a critic produced no verdict: a reason code in an open vocabulary (`error`, `timeout`, `rate-limited`, `invalid-output`) and a message that holds no credential. */
|
|
64
|
+
export const CritiqueFailureSchema = z.looseObject({
|
|
65
|
+
reason: z.string().min(1),
|
|
66
|
+
message: z.string().optional()
|
|
67
|
+
});
|
|
68
|
+
/**
|
|
69
|
+
* The `atlas.critique` extension, version 1: the image judged (`subject`), who judged (`critic`, `model`,
|
|
70
|
+
* `parameters`), the opinion (`verdict`, `findings`) or why there is none (`failure`), and what it cost (`usage`).
|
|
71
|
+
* The prompt is recorded only as a hash (`parameters.rubric`). Emitted as `critique.schema.json`.
|
|
72
|
+
*/
|
|
73
|
+
export const CritiqueSchema = z.looseObject({
|
|
74
|
+
schema: z.number().int().positive().describe('The version of this extension. 1'),
|
|
75
|
+
subject: z.looseObject({
|
|
76
|
+
entryKey: z.string().min(1).describe('The catalog entry that was judged'),
|
|
77
|
+
imageSha256: z.string().min(1).describe('The hash of the exact image bytes that were judged. When the entry\'s image changes, this critique judged an earlier image'),
|
|
78
|
+
state: z.string().min(1).optional().describe('The kind of UI state: `screen`, `overlay`, `loading`, `error`, `empty`…')
|
|
79
|
+
}),
|
|
80
|
+
critic: z.looseObject({
|
|
81
|
+
name: z.string().min(1),
|
|
82
|
+
version: z.string().min(1).describe('Of the critic\'s own logic. A newer version replaces an older one'),
|
|
83
|
+
kind: z.string().min(1).optional().describe('Open vocabulary: `model`, `llm`, `ml`, `heuristic`…')
|
|
84
|
+
}),
|
|
85
|
+
model: z.looseObject({ id: z.string().min(1), provider: z.string().optional() }).optional(),
|
|
86
|
+
parameters: z.record(z.string(), z.unknown()).optional().describe('Everything else that explains the result: temperature, seed, the SHA-256 of the rubric'),
|
|
87
|
+
verdict: CritiqueVerdictSchema.optional(),
|
|
88
|
+
findings: z.array(CritiqueFindingSchema).optional(),
|
|
89
|
+
failure: CritiqueFailureSchema.optional(),
|
|
90
|
+
usage: z.looseObject({
|
|
91
|
+
inputTokens: z.number().nonnegative().optional(),
|
|
92
|
+
outputTokens: z.number().nonnegative().optional(),
|
|
93
|
+
latencyMs: z.number().nonnegative().optional(),
|
|
94
|
+
costUsd: z.number().nonnegative().optional()
|
|
95
|
+
}).optional()
|
|
96
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @module Schema package entry point (`@crvouga/atlas/schema`)
|
|
3
3
|
*
|
|
4
4
|
* Re-exports every Zod schema, constant and inferred type of the Atlas contract: the layer format and report status
|
|
5
|
-
* (`layer`), the statechart run manifest (`manifest`, `common`), the spec file formats (`spec`), the run index and
|
|
5
|
+
* (`layer`), the `atlas.critique` evidence of automated UI reviewers (`critique`), the statechart run manifest (`manifest`, `common`), the spec file formats (`spec`), the run index and
|
|
6
6
|
* report sources the viewer reads (`runs-index`, `report-sources`) and the server configuration (`server-config`).
|
|
7
7
|
* The JSON Schemas shipped in `schemas/` are generated from these by `bun run schemas`.
|
|
8
8
|
*
|
|
@@ -14,6 +14,7 @@ export * from './runs-index.js';
|
|
|
14
14
|
export * from './spec.js';
|
|
15
15
|
export * from './report-sources.js';
|
|
16
16
|
export * from './layer.js';
|
|
17
|
+
export * from './critique.js';
|
|
17
18
|
export * from './server-config.js';
|
|
18
19
|
export * from './user-state.js';
|
|
19
20
|
export * from './api.js';
|
package/dist/schema/src/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @module Schema package entry point (`@crvouga/atlas/schema`)
|
|
3
3
|
*
|
|
4
4
|
* Re-exports every Zod schema, constant and inferred type of the Atlas contract: the layer format and report status
|
|
5
|
-
* (`layer`), the statechart run manifest (`manifest`, `common`), the spec file formats (`spec`), the run index and
|
|
5
|
+
* (`layer`), the `atlas.critique` evidence of automated UI reviewers (`critique`), the statechart run manifest (`manifest`, `common`), the spec file formats (`spec`), the run index and
|
|
6
6
|
* report sources the viewer reads (`runs-index`, `report-sources`) and the server configuration (`server-config`).
|
|
7
7
|
* The JSON Schemas shipped in `schemas/` are generated from these by `bun run schemas`.
|
|
8
8
|
*
|
|
@@ -14,6 +14,7 @@ export * from './runs-index.js';
|
|
|
14
14
|
export * from './spec.js';
|
|
15
15
|
export * from './report-sources.js';
|
|
16
16
|
export * from './layer.js';
|
|
17
|
+
export * from './critique.js';
|
|
17
18
|
export * from './server-config.js';
|
|
18
19
|
export * from './user-state.js';
|
|
19
20
|
export * from './api.js';
|
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
* @see docs/server.md
|
|
12
12
|
*/
|
|
13
13
|
import { z } from 'zod/v4';
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* What a bearer token may do: `read` (look), `critic` (also publish critiques, and nothing else), `publish` (also upload
|
|
16
|
+
* anything) or `admin` (also remove scratch objects).
|
|
17
|
+
*/
|
|
15
18
|
export declare const ServerRoleSchema: z.ZodEnum<{
|
|
16
19
|
read: "read";
|
|
20
|
+
critic: "critic";
|
|
17
21
|
publish: "publish";
|
|
18
22
|
admin: "admin";
|
|
19
23
|
}>;
|
|
@@ -27,6 +31,7 @@ export declare const ServerTokenSchema: z.ZodObject<{
|
|
|
27
31
|
name: z.ZodString;
|
|
28
32
|
role: z.ZodEnum<{
|
|
29
33
|
read: "read";
|
|
34
|
+
critic: "critic";
|
|
30
35
|
publish: "publish";
|
|
31
36
|
admin: "admin";
|
|
32
37
|
}>;
|
|
@@ -45,6 +50,7 @@ export declare const ServerConfigSchema: z.ZodObject<{
|
|
|
45
50
|
name: z.ZodString;
|
|
46
51
|
role: z.ZodEnum<{
|
|
47
52
|
read: "read";
|
|
53
|
+
critic: "critic";
|
|
48
54
|
publish: "publish";
|
|
49
55
|
admin: "admin";
|
|
50
56
|
}>;
|
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
* @see docs/server.md
|
|
12
12
|
*/
|
|
13
13
|
import { z } from 'zod/v4';
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* What a bearer token may do: `read` (look), `critic` (also publish critiques, and nothing else), `publish` (also upload
|
|
16
|
+
* anything) or `admin` (also remove scratch objects).
|
|
17
|
+
*/
|
|
18
|
+
export const ServerRoleSchema = z.enum(['read', 'critic', 'publish', 'admin']);
|
|
16
19
|
/**
|
|
17
20
|
* A bearer token: a name for logs, a role, and the token itself or the name of the environment variable that holds it
|
|
18
21
|
* (exactly one of the two).
|
|
@@ -20,7 +23,7 @@ export const ServerRoleSchema = z.enum(['read', 'publish', 'admin']);
|
|
|
20
23
|
export const ServerTokenSchema = z
|
|
21
24
|
.strictObject({
|
|
22
25
|
name: z.string().min(1).describe('Shown in logs, never the token itself'),
|
|
23
|
-
role: ServerRoleSchema.describe('`read` may look. `publish` may also upload. `admin` may also remove scratch objects'),
|
|
26
|
+
role: ServerRoleSchema.describe('`read` may look. `critic` may also publish critiques (layers of producer `critic.*` whose parts are all of kind `critique`). `publish` may also upload. `admin` may also remove scratch objects'),
|
|
24
27
|
token: z.string().min(12).optional().describe('The token. Prefer `tokenEnv`, so the file holds no secret'),
|
|
25
28
|
tokenEnv: z.string().optional().describe('The name of the environment variable that holds the token')
|
|
26
29
|
})
|
package/dist/server/src/app.js
CHANGED
|
@@ -28,6 +28,7 @@ import { listRunSummaries, readRunFile } from '../../reports/src/run-bundle.js';
|
|
|
28
28
|
import { latestSpecSet, readSpecBody } from '../../reports/src/spec-set.js';
|
|
29
29
|
import { catalogHealth, healthMarkdown, parseRootMappings } from '../../reports/src/catalog-health.js';
|
|
30
30
|
import { parseArtifactFilter } from '../../reports/src/artifact-query.js';
|
|
31
|
+
import { filterUiCatalog, pageUiCatalog, parseUiCatalogQuery, uiCatalog, uiCatalogMarkdown } from '../../reports/src/ui-catalog.js';
|
|
31
32
|
import { parseReliabilityScope, reliability, reliabilityMarkdown } from '../../reports/src/reliability.js';
|
|
32
33
|
import { artifactList, artifactsMarkdown, catalogMarkdown, catalogModel, historyMarkdown, parseTestQuery, runDetail, runList, runMarkdown, runsMarkdown, testCatalog, testHistory } from '../../reports/src/test-catalog.js';
|
|
33
34
|
import { llmsTxt } from './llms.js';
|
|
@@ -418,6 +419,7 @@ function createProjectApplication(options) {
|
|
|
418
419
|
}
|
|
419
420
|
return model.value;
|
|
420
421
|
};
|
|
422
|
+
let uiModel;
|
|
421
423
|
const wantsMarkdown = (c) => {
|
|
422
424
|
const format = c.req.query('format');
|
|
423
425
|
return format === 'markdown' || format === 'md' || (format === undefined && /\btext\/markdown\b/.test(c.req.header('accept') ?? ''));
|
|
@@ -502,6 +504,19 @@ function createProjectApplication(options) {
|
|
|
502
504
|
const document = () => artifactList(index.history, found.version, parseArtifactFilter(search), { limit: number('limit', 100), offset: number('offset', 0), domainOf: path => domains.get(path) });
|
|
503
505
|
return answer(c, found.version, document, () => artifactsMarkdown(document()), `artifacts|${search.toString()}|${wantsMarkdown(c)}`);
|
|
504
506
|
});
|
|
507
|
+
app.get('/api/reports/ui-catalog', async (c) => {
|
|
508
|
+
if (!index.ready)
|
|
509
|
+
return notReady(c);
|
|
510
|
+
const snapshot = index.snapshot();
|
|
511
|
+
const search = new URL(c.req.url).searchParams;
|
|
512
|
+
const number = (name, fallback) => (/^\d+$/.test(search.get(name) ?? '') ? Number(search.get(name)) : fallback);
|
|
513
|
+
// The catalog is a function of the report, so it is built once per version and narrowed per question.
|
|
514
|
+
if (uiModel?.version !== snapshot.version)
|
|
515
|
+
uiModel = { version: snapshot.version, value: uiCatalog(snapshot.layers, { version: snapshot.version, backends: snapshot.backends, notices: snapshot.notices }) };
|
|
516
|
+
const whole = uiModel.value;
|
|
517
|
+
const document = () => pageUiCatalog(filterUiCatalog(whole, parseUiCatalogQuery(search)), { limit: number('limit', 200), offset: number('offset', 0) });
|
|
518
|
+
return answer(c, snapshot.version, document, () => uiCatalogMarkdown(document()), `ui-catalog|${search.toString()}|${wantsMarkdown(c)}`);
|
|
519
|
+
});
|
|
505
520
|
app.get('/api/reports/runs/detail', async (c) => {
|
|
506
521
|
if (!index.ready)
|
|
507
522
|
return notReady(c);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module Authentication and roles
|
|
3
3
|
*
|
|
4
|
-
* Who is asking, and may they. Tokens have a role (`read` < `publish` < `admin`) and are compared in constant time. Reading
|
|
4
|
+
* Who is asking, and may they. Tokens have a role (`read` < `critic` < `publish` < `admin`) and are compared in constant time. Reading
|
|
5
5
|
* accepts a Bearer token or the browser's sign-in box (any user name, the token as the password); changing anything needs
|
|
6
6
|
* the Bearer header, because a browser sends the sign-in box's credentials to any page that asks, which would let another
|
|
7
7
|
* site upload with them. A server with no tokens is open, and refuses to listen beyond this machine unless told it may.
|
package/dist/server/src/auth.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module Authentication and roles
|
|
3
3
|
*
|
|
4
|
-
* Who is asking, and may they. Tokens have a role (`read` < `publish` < `admin`) and are compared in constant time. Reading
|
|
4
|
+
* Who is asking, and may they. Tokens have a role (`read` < `critic` < `publish` < `admin`) and are compared in constant time. Reading
|
|
5
5
|
* accepts a Bearer token or the browser's sign-in box (any user name, the token as the password); changing anything needs
|
|
6
6
|
* the Bearer header, because a browser sends the sign-in box's credentials to any page that asks, which would let another
|
|
7
7
|
* site upload with them. A server with no tokens is open, and refuses to listen beyond this machine unless told it may.
|
|
8
8
|
*/
|
|
9
9
|
import { timingSafeEqual } from 'node:crypto';
|
|
10
|
-
const RANK = { read: 1,
|
|
10
|
+
const RANK = { read: 1, critic: 2, publish: 3, admin: 4 };
|
|
11
11
|
function same(a, b) {
|
|
12
12
|
const left = Buffer.from(a), right = Buffer.from(b);
|
|
13
13
|
return left.length === right.length && timingSafeEqual(left, right);
|
|
@@ -60,7 +60,7 @@ export const SERVER_SETTINGS = [
|
|
|
60
60
|
{ summary: 'How often (seconds) the backends are looked at for layers written by something else', key: 'refreshSeconds', flag: '--refresh <seconds>', env: ['ATLAS_REFRESH_SECONDS'], default: '`10`', example: { value: '30', read: c => c.refreshSeconds, expected: 30 } },
|
|
61
61
|
{ summary: 'Let anyone look, not upload', key: 'auth.anonymousRead', flag: '--anonymous-read', env: ['ATLAS_ANONYMOUS_READ'], default: 'no', example: { value: '1', read: c => c.anonymousRead, expected: true } },
|
|
62
62
|
{ summary: 'Listen publicly with no tokens (something in front signs people in)', key: 'auth.insecure', flag: '--insecure', env: ['ATLAS_INSECURE'], default: 'no', example: { value: '1', read: c => c.insecure, expected: true } },
|
|
63
|
-
{ summary: 'The admin token (also `ATLAS_PUBLISH_TOKEN` and `ATLAS_READ_TOKEN` for the other roles, or `auth.tokens` in the file)', key: 'auth.tokens', flag: null, env: ['ATLAS_TOKEN'], default: 'none: the server is open and listens on this machine only', example: { value: 'admin-token-123456', read: c => c.tokens.map(token => token.role), expected: ['admin'] } },
|
|
63
|
+
{ summary: 'The admin token (also `ATLAS_PUBLISH_TOKEN`, `ATLAS_CRITIC_TOKEN` and `ATLAS_READ_TOKEN` for the other roles, or `auth.tokens` in the file)', key: 'auth.tokens', flag: null, env: ['ATLAS_TOKEN'], default: 'none: the server is open and listens on this machine only', example: { value: 'admin-token-123456', read: c => c.tokens.map(token => token.role), expected: ['admin'] } },
|
|
64
64
|
{ summary: 'The configuration file', key: '(the file itself)', flag: '--config <file>', env: ['ATLAS_CONFIG'], default: '`./atlas.server.json` when it exists', example: { value: 'custom.json', read: c => path.basename(c.configFile ?? ''), expected: 'custom.json' } },
|
|
65
65
|
{ summary: 'Largest uploaded object in MiB', key: 'limits.maxObjectMiB', flag: null, env: ['ATLAS_MAX_OBJECT_MIB'], default: '`256`', example: { value: '64', read: c => c.limits.maxObjectMiB, expected: 64 } },
|
|
66
66
|
{ summary: 'Largest JSON upload in MiB', key: 'limits.maxJsonMiB', flag: null, env: ['ATLAS_MAX_JSON_MIB'], default: '`32`', example: { value: '8', read: c => c.limits.maxJsonMiB, expected: 8 } },
|
|
@@ -172,7 +172,7 @@ export function loadServerConfig(argv, env, cwd, read = file => readFileSync(fil
|
|
|
172
172
|
throw new ConfigError(`Token "${item.name}" reads ${item.tokenEnv} and it is not set`);
|
|
173
173
|
tokens.push({ name: item.name, role: item.role, token });
|
|
174
174
|
}
|
|
175
|
-
for (const [variable, role, name] of [['ATLAS_TOKEN', 'admin', 'admin'], ['ATLAS_PUBLISH_TOKEN', 'publish', 'publish'], ['ATLAS_READ_TOKEN', 'read', 'read']]) {
|
|
175
|
+
for (const [variable, role, name] of [['ATLAS_TOKEN', 'admin', 'admin'], ['ATLAS_PUBLISH_TOKEN', 'publish', 'publish'], ['ATLAS_CRITIC_TOKEN', 'critic', 'critic'], ['ATLAS_READ_TOKEN', 'read', 'read']]) {
|
|
176
176
|
if (env[variable])
|
|
177
177
|
tokens.push({ name, role, token: env[variable].trim() });
|
|
178
178
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The version of the API described, `major.minor.patch`: the minor grows when an operation or field is added, the major never within `/api`. */
|
|
2
|
-
export declare const API_VERSION = "1.
|
|
3
|
-
/** Who may call an operation: `none` (anyone), or the least role that may (`read` < `publish` < `admin`). */
|
|
4
|
-
export type Role = 'none' | 'read' | 'publish' | 'admin';
|
|
2
|
+
export declare const API_VERSION = "1.6.0";
|
|
3
|
+
/** Who may call an operation: `none` (anyone), or the least role that may (`read` < `critic` < `publish` < `admin`). */
|
|
4
|
+
export type Role = 'none' | 'read' | 'critic' | 'publish' | 'admin';
|
|
5
5
|
type JsonSchema = Record<string, unknown>;
|
|
6
6
|
type Param = {
|
|
7
7
|
readonly name: string;
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { z } from 'zod/v4';
|
|
16
16
|
import { ProjectsSchema } from '../../schema/src/project.js';
|
|
17
|
-
import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema, TestCatalogSchema, CatalogHealthSchema, TestHistorySchema, TestReliabilitySchema, RunListSchema, RunDetailSchema, ArtifactListSchema, ArtifactTagEditSchema, ArtifactTagEntrySchema, ArtifactTagsSchema, ReviewCollectionRequestSchema, ReviewCollectionSchema, ReviewCollectionListSchema, ReviewCollectionDetailSchema, ReviewNoteInputSchema, ReviewNoteSchema, ReviewManifestSchema } from '../../schema/src/api.js';
|
|
17
|
+
import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema, TestCatalogSchema, CatalogHealthSchema, TestHistorySchema, TestReliabilitySchema, RunListSchema, RunDetailSchema, ArtifactListSchema, UiCatalogSchema, ArtifactTagEditSchema, ArtifactTagEntrySchema, ArtifactTagsSchema, ReviewCollectionRequestSchema, ReviewCollectionSchema, ReviewCollectionListSchema, ReviewCollectionDetailSchema, ReviewNoteInputSchema, ReviewNoteSchema, ReviewManifestSchema } from '../../schema/src/api.js';
|
|
18
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
|
-
export const API_VERSION = '1.
|
|
20
|
+
export const API_VERSION = '1.6.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. */
|
|
22
22
|
function schemaOf(schema, io = 'output') {
|
|
23
23
|
const { $schema: _dialect, ...json } = z.toJSONSchema(schema, { target: 'draft-2020-12', unrepresentable: 'any', io });
|
|
@@ -38,7 +38,7 @@ const COMPONENTS = {
|
|
|
38
38
|
Outline: [OutlineSchema, 'output'], EvidencePage: [EvidencePageSchema, 'output'], LayerRows: [LayerRowsSchema, 'output'], CheckDetail: [CheckDetailSchema, 'output'],
|
|
39
39
|
ArtifactTagEdit: [ArtifactTagEditSchema, 'input'], ArtifactTagEntry: [ArtifactTagEntrySchema, 'output'], ArtifactTags: [ArtifactTagsSchema, 'output'],
|
|
40
40
|
ReviewCollectionRequest: [ReviewCollectionRequestSchema, 'input'], ReviewCollection: [ReviewCollectionSchema, 'output'], ReviewCollectionList: [ReviewCollectionListSchema, 'output'], ReviewCollectionDetail: [ReviewCollectionDetailSchema, 'output'], ReviewNoteInput: [ReviewNoteInputSchema, 'input'], ReviewNote: [ReviewNoteSchema, 'output'], ReviewManifest: [ReviewManifestSchema, 'output'],
|
|
41
|
-
TestCatalog: [TestCatalogSchema, 'output'], CatalogHealth: [CatalogHealthSchema, 'output'], TestHistory: [TestHistorySchema, 'output'], TestReliability: [TestReliabilitySchema, 'output'], RunList: [RunListSchema, 'output'], ArtifactList: [ArtifactListSchema, 'output'], RunDetail: [RunDetailSchema, 'output'],
|
|
41
|
+
TestCatalog: [TestCatalogSchema, 'output'], CatalogHealth: [CatalogHealthSchema, 'output'], TestHistory: [TestHistorySchema, 'output'], TestReliability: [TestReliabilitySchema, 'output'], RunList: [RunListSchema, 'output'], ArtifactList: [ArtifactListSchema, 'output'], UiCatalog: [UiCatalogSchema, 'output'], RunDetail: [RunDetailSchema, 'output'],
|
|
42
42
|
SpecIndex: [SpecIndexSchema, 'output'], ReportSources: [ReportSourcesSchema, 'output'], RunsIndex: [RunsIndexSchema, 'output'],
|
|
43
43
|
RunEvent: [LiveRunSchema, 'output'], HelloEvent: [HelloEventSchema, 'output'], ReportEvent: [ReportEventSchema, 'output'], AtlasChangeEvent: [AtlasChangeSchema, 'output'], PingEvent: [PingEventSchema, 'output']
|
|
44
44
|
};
|
|
@@ -246,7 +246,7 @@ const BASE_OPERATIONS = [
|
|
|
246
246
|
responses: { '200': { description: 'It exists.' }, '401': UNAUTHORIZED, '404': problem('No such object.') }
|
|
247
247
|
},
|
|
248
248
|
{
|
|
249
|
-
route: 'PUT /api/store/*', path: '/api/store/{key}', operationId: 'putObject', tag: 'Store protocol', role: '
|
|
249
|
+
route: 'PUT /api/store/*', path: '/api/store/{key}', operationId: 'putObject', tag: 'Store protocol', role: 'critic',
|
|
250
250
|
summary: 'Upload an object',
|
|
251
251
|
description: [
|
|
252
252
|
'The write half of the `ObjectStore` interface. The server checks what it can from the bytes in hand and says no to what would corrupt the report for everyone:',
|
|
@@ -257,7 +257,9 @@ const BASE_OPERATIONS = [
|
|
|
257
257
|
'- a **run manifest** must have passed its privacy scan;',
|
|
258
258
|
'- keys are immutable: the same bytes again are `200` (a retry), other bytes under the same key are `409`.',
|
|
259
259
|
'',
|
|
260
|
-
'Upload in dependency order: blobs, then the manifest, then the catalog entry. `atlas report publish`, `atlas report stream` and `httpStore` do. Needs a `Bearer` token of role `publish` or `admin`; a browser\'s sign-in box is refused.'
|
|
260
|
+
'Upload in dependency order: blobs, then the manifest, then the catalog entry. `atlas report publish`, `atlas report stream` and `httpStore` do. Needs a `Bearer` token of role `publish` or `admin`; a browser\'s sign-in box is refused.',
|
|
261
|
+
'',
|
|
262
|
+
'A token of role `critic` may upload only what a critic publishes: blobs, and layers (and their catalog entries) whose `producer` is `critic.<name>` and whose parts are all of kind `critique`. Anything else is `403` with the rule it broke, and nothing is stored.'
|
|
261
263
|
].join('\n'),
|
|
262
264
|
parameters: [KEY, { name: 'content-type', in: 'header', description: 'Stored with the object and returned when it is read.', schema: str }],
|
|
263
265
|
body: { description: 'The object\'s bytes.', contentType: 'application/octet-stream', schema: { type: 'string', format: 'binary' }, required: true },
|
|
@@ -458,6 +460,26 @@ const BASE_OPERATIONS = [
|
|
|
458
460
|
],
|
|
459
461
|
responses: { '200': json('The matching results and the facets.', 'ArtifactList'), '304': { description: 'Unchanged since the `If-None-Match` ETag.' }, '401': UNAUTHORIZED, '503': NOT_READY }
|
|
460
462
|
},
|
|
463
|
+
{
|
|
464
|
+
route: 'GET /api/reports/ui-catalog', path: '/api/reports/ui-catalog', operationId: 'getUiCatalog', tag: 'Report', role: 'read',
|
|
465
|
+
summary: 'The UI catalog: captured UI states per profile, and what is still expected',
|
|
466
|
+
description: 'A browsable inventory of the product\'s user interface, computed from the winning results of the `ui-state` and `ui-plan` parts of every layer (see the UI catalog guide). Each state is a check id; each cell is one profile of it (the layer\'s environment and parameters) with its coverage: `captured` only when the winning capture passed, `failed`, `capturing`, `skipped`, or `missing` when a plan expects it and nothing has been captured. A picture is read from the cell\'s `capture.image.path` under the reports API root, or `uri`. What is expected is itself a layer, so a shard that never published leaves its states `missing`, never captured. The document equals what the viewer computes from the same report. Narrow it by `q` (words), `coverage`, `profile` (a profile `key`), `area`, `route`, `cohort`, `flag`, `surface` and `producer`: AND across parameters, OR within one; a state keeps only the cells that match `profile` and `coverage`. Page the states with `limit` and `offset`. Viewers need not poll: they hold the report over `/api/events` and read changes with `/api/reports/evidence/effective?since=`.',
|
|
467
|
+
parameters: [
|
|
468
|
+
{ name: 'limit', in: 'query', description: 'At most this many states (default 200).', schema: { type: 'integer', minimum: 0 } },
|
|
469
|
+
{ name: 'offset', in: 'query', description: 'Skip this many states (default 0).', schema: { type: 'integer', minimum: 0 } },
|
|
470
|
+
{ name: 'q', in: 'query', description: 'Words that must all appear in a state\'s id, title, route, fixture, cohort, area, flags, surfaces or producers.', schema: str },
|
|
471
|
+
{ name: 'coverage', in: 'query', description: 'Coverage values: `captured`, `failed`, `capturing`, `skipped`, `missing`. Comma-separated.', schema: str },
|
|
472
|
+
{ name: 'profile', in: 'query', description: 'Profile keys, as listed in `profiles[].key`.', schema: str },
|
|
473
|
+
{ name: 'area', in: 'query', description: 'Areas.', schema: str },
|
|
474
|
+
{ name: 'route', in: 'query', description: 'Routes.', schema: str },
|
|
475
|
+
{ name: 'cohort', in: 'query', description: 'Cohorts.', schema: str },
|
|
476
|
+
{ name: 'flag', in: 'query', description: 'Feature flags a state was captured with.', schema: str },
|
|
477
|
+
{ name: 'surface', in: 'query', description: 'Surfaces a state covers.', schema: str },
|
|
478
|
+
{ name: 'producer', in: 'query', description: 'Producers that captured or planned a state.', schema: str },
|
|
479
|
+
{ name: 'format', in: 'query', description: '`markdown` for Markdown instead of JSON.', schema: { type: 'string', enum: ['json', 'markdown'] } }
|
|
480
|
+
],
|
|
481
|
+
responses: { '200': json('The catalog.', 'UiCatalog'), '304': { description: 'Unchanged since the `If-None-Match` ETag.' }, '401': UNAUTHORIZED, '503': NOT_READY }
|
|
482
|
+
},
|
|
461
483
|
{
|
|
462
484
|
route: 'GET /api/reports/runs/detail', path: '/api/reports/runs/detail', operationId: 'getReportRun', tag: 'Report', role: 'read',
|
|
463
485
|
summary: 'Every test one run recorded',
|
|
@@ -590,6 +612,7 @@ export const NOT_OPERATIONS = new Map([
|
|
|
590
612
|
const SECURITY = {
|
|
591
613
|
none: [],
|
|
592
614
|
read: [{ bearer: [] }, { basic: [] }],
|
|
615
|
+
critic: [{ bearer: [] }],
|
|
593
616
|
publish: [{ bearer: [] }],
|
|
594
617
|
admin: [{ bearer: [] }]
|
|
595
618
|
};
|
|
@@ -623,7 +646,7 @@ export function openApiDocument(options = {}) {
|
|
|
623
646
|
description: [
|
|
624
647
|
'One Atlas node serves multiple projects. Clients select a project using ?project=<slug>, X-Atlas-Project, or /api/projects/<slug>/… . Each project has independent specs, layers, recordings, status, and event streams. Within each project, the newest completed result of every check wins its slot. Unqualified requests address the legacy default project.',
|
|
625
648
|
'',
|
|
626
|
-
'**Authentication.** With tokens configured, send `Authorization: Bearer <token>`. Roles: `read` < `publish` < `admin`. Uploading needs a Bearer token; a browser\'s HTTP Basic sign-in (any user name, the token as the password) is accepted for reading only. A server with no tokens is open and listens on this machine only unless told otherwise.',
|
|
649
|
+
'**Authentication.** With tokens configured, send `Authorization: Bearer <token>`. Roles: `read` < `critic` < `publish` < `admin`. A `critic` token may read, and publish critiques (layers of automated reviewers) and nothing else. Uploading needs a Bearer token; a browser\'s HTTP Basic sign-in (any user name, the token as the password) is accepted for reading only. A server with no tokens is open and listens on this machine only unless told otherwise.',
|
|
627
650
|
'',
|
|
628
651
|
'**Errors** are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem documents (`application/problem+json`) with a `detail` that says what to do. `401` send a token, `403` that token may not, `409` upload what this depends on first or you are changing something immutable, `413` too large, `422` not valid, `503` still starting (retry after `Retry-After`).',
|
|
629
652
|
'',
|
|
@@ -12,6 +12,7 @@ import { Hono } from 'hono';
|
|
|
12
12
|
import { isPaged } from '../../reports/src/object-store.js';
|
|
13
13
|
import { readBytes } from '../../reports/src/object-io.js';
|
|
14
14
|
import { UNTRUSTED_HEADERS } from '../../reports/src/attachment-response.js';
|
|
15
|
+
import { criticScopeViolation } from '../../critics/src/scope.js';
|
|
15
16
|
import { classify, isCatalog, Refusal, sameBytes, validateUpload, validKey } from './validate.js';
|
|
16
17
|
/** Read a request body, refusing as soon as it is over the limit rather than after holding it all. */
|
|
17
18
|
export async function readLimited(request, limit) {
|
|
@@ -55,6 +56,40 @@ const decode = (c, prefix) => {
|
|
|
55
56
|
return null;
|
|
56
57
|
}
|
|
57
58
|
};
|
|
59
|
+
const decoder = new TextDecoder();
|
|
60
|
+
/**
|
|
61
|
+
* What a critic-role token may upload: blobs, and layers (and the catalog entries that make them visible) whose producer
|
|
62
|
+
* is `critic.<name>` and whose parts are all of kind `critique`. Anything else, including a key that is not a layer at
|
|
63
|
+
* all, is refused with `403` and the rule it broke, before anything is stored. Blobs are checked against their name by
|
|
64
|
+
* the upload validation, so on their own they are harmless.
|
|
65
|
+
*/
|
|
66
|
+
async function assertCriticMayStore(name, bytes, store) {
|
|
67
|
+
const known = classify(name);
|
|
68
|
+
if (known?.kind === 'blob')
|
|
69
|
+
return;
|
|
70
|
+
if (known?.kind !== 'layer' && known?.kind !== 'layer-catalog')
|
|
71
|
+
throw new Refusal(403, `A critic token may upload only critiques, not "${name}". Use a publish token for anything else.`);
|
|
72
|
+
let text;
|
|
73
|
+
if (known.kind === 'layer')
|
|
74
|
+
text = decoder.decode(bytes);
|
|
75
|
+
else {
|
|
76
|
+
const stored = await store.get(`layers/manifests/${known.id}.json`);
|
|
77
|
+
// A catalog entry for a layer that is not there is refused by the upload validation, as for everyone.
|
|
78
|
+
if (!stored)
|
|
79
|
+
return;
|
|
80
|
+
text = decoder.decode(await readBytes(stored.body));
|
|
81
|
+
}
|
|
82
|
+
let layer;
|
|
83
|
+
try {
|
|
84
|
+
layer = JSON.parse(text);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const violation = criticScopeViolation(layer ?? {});
|
|
90
|
+
if (violation)
|
|
91
|
+
throw new Refusal(403, violation);
|
|
92
|
+
}
|
|
58
93
|
/**
|
|
59
94
|
* The store protocol: the seven operations of an object store over HTTP, for what the server keeps. A client
|
|
60
95
|
* that publishes to it uses the same code that publishes to a folder or a bucket. It differs from a bucket in
|
|
@@ -134,7 +169,8 @@ export function storeApi(options) {
|
|
|
134
169
|
return new Response(stored.body, { headers });
|
|
135
170
|
});
|
|
136
171
|
app.put('/*', async (c) => {
|
|
137
|
-
|
|
172
|
+
// A critic may upload too, but only what a critic publishes (see `assertCriticMayStore`).
|
|
173
|
+
const verdict = auth.check(c.req.raw, 'critic');
|
|
138
174
|
if (!verdict.ok)
|
|
139
175
|
return refuse(c, verdict.status, verdict.message);
|
|
140
176
|
const name = key(c);
|
|
@@ -143,6 +179,8 @@ export function storeApi(options) {
|
|
|
143
179
|
try {
|
|
144
180
|
const bytes = await readLimited(c.req.raw, options.maxObjectBytes);
|
|
145
181
|
return await exclusive(name, async () => {
|
|
182
|
+
if (verdict.identity.role === 'critic')
|
|
183
|
+
await assertCriticMayStore(name, bytes, store);
|
|
146
184
|
const kind = await validateUpload(name, bytes, store, layer => { recent.set(layer.id, layer); if (recent.size > 8)
|
|
147
185
|
recent.delete(recent.keys().next().value); });
|
|
148
186
|
// A blob's name is the digest of what is in it, which was just checked: one that is there is this one, however it was compressed.
|
package/docs/agents.md
CHANGED
|
@@ -103,6 +103,7 @@ The complete public export map is generated from the package manifest (wildcards
|
|
|
103
103
|
@crvouga/atlas/vitest
|
|
104
104
|
@crvouga/atlas/schema
|
|
105
105
|
@crvouga/atlas/server
|
|
106
|
+
@crvouga/atlas/critics
|
|
106
107
|
@crvouga/atlas/reports/*
|
|
107
108
|
@crvouga/atlas/schemas/*
|
|
108
109
|
@crvouga/atlas/openapi.json
|