@sigrun/sigrun 0.2.0 → 0.3.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 (72) hide show
  1. package/CHANGELOG.md +145 -7
  2. package/README.md +333 -13
  3. package/dist/blocks/build-stamp.json +13 -0
  4. package/dist/blocks/builders.d.ts +57 -0
  5. package/dist/blocks/images.d.ts +26 -0
  6. package/dist/blocks/index.d.ts +20 -0
  7. package/dist/blocks/limits.d.ts +25 -0
  8. package/dist/blocks/placements.d.ts +26 -0
  9. package/dist/blocks/schema.d.ts +38 -0
  10. package/dist/blocks/validate.d.ts +73 -0
  11. package/dist/cli.js +117 -19
  12. package/dist/config.js +21 -2
  13. package/dist/extension.d.ts +48 -8
  14. package/dist/harness/ambient.js +2 -0
  15. package/dist/harness/blocks/state.js +1 -0
  16. package/dist/harness/boot.js +92 -6
  17. package/dist/harness/builtins.js +1 -1
  18. package/dist/harness/context.js +25 -0
  19. package/dist/harness/event-bus.js +25 -14
  20. package/dist/harness/identity/accounts.js +3 -2
  21. package/dist/harness/identity/index.js +2 -1
  22. package/dist/harness/identity/invites.js +26 -9
  23. package/dist/harness/identity/link-sweep.js +47 -0
  24. package/dist/harness/identity/typed-username.js +15 -0
  25. package/dist/harness/identity/users.js +10 -7
  26. package/dist/harness/interactions.js +2 -0
  27. package/dist/harness/loader.js +57 -1
  28. package/dist/harness/migration-snapshot.js +97 -0
  29. package/dist/harness/reports.js +100 -0
  30. package/dist/harness/seam-host.js +23 -1
  31. package/dist/harness/sections/context-files.js +1 -1
  32. package/dist/harness/settings.js +52 -4
  33. package/dist/jobs/docs.js +3 -0
  34. package/dist/jobs/fire.js +2 -0
  35. package/dist/jobs/parked.js +38 -0
  36. package/dist/jobs/task.js +37 -8
  37. package/dist/sandbox/availability.js +69 -20
  38. package/dist/sandbox/launch.js +11 -0
  39. package/dist/sandbox/policy.js +24 -3
  40. package/dist/sandbox/worker.js +4 -24
  41. package/dist/serve.js +162 -40
  42. package/dist/server/auth/ip.js +9 -4
  43. package/dist/server/auth/own-host.js +115 -0
  44. package/dist/server/auth/pipeline.js +93 -28
  45. package/dist/server/auth/proxy-diagnostic.js +60 -0
  46. package/dist/server/auth/proxy-identity.js +134 -0
  47. package/dist/server/auth/proxy-only.js +28 -0
  48. package/dist/server/images/cache.js +13 -5
  49. package/dist/server/index.js +10 -2
  50. package/dist/server/login/resolve.js +88 -26
  51. package/dist/server/notifications.js +21 -4
  52. package/dist/server/routes/admin.js +97 -0
  53. package/dist/server/routes/images.js +16 -9
  54. package/dist/server/routes/login.js +8 -0
  55. package/dist/server/routes/me.js +1 -1
  56. package/dist/server/routes/meta.js +4 -0
  57. package/dist/server/routes/metrics.js +18 -2
  58. package/dist/server/routes/onboarding.js +97 -37
  59. package/dist/server/session-meta.js +1 -0
  60. package/dist/server/shutdown.js +93 -16
  61. package/dist/settings/registry.js +6 -0
  62. package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
  63. package/node_modules/@sigrun/shared/dist/settings.d.ts +14 -0
  64. package/node_modules/@sigrun/shared/dist/usernames.d.ts +8 -0
  65. package/node_modules/@sigrun/shared/dist/usernames.js +39 -0
  66. package/node_modules/@sigrun/shared/dist/views.d.ts +33 -4
  67. package/node_modules/@sigrun/shared/package.json +5 -1
  68. package/package.json +16 -15
  69. package/webui/dist/assets/{index-CxB969qp.js → index-CEOzj1C4.js} +11 -11
  70. package/webui/dist/index.html +1 -1
  71. package/CONTEXT.md +0 -163
  72. package/dist/server/login/usernames.js +0 -15
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The typed builders (spec §14.1): one function per block and field,
3
+ * plus `bind(pointer)` and `tree(root)`. Builders return plain JSON —
4
+ * there is no runtime besides them.
5
+ *
6
+ * Each builder takes its block's props object and returns the block
7
+ * with the `type` literal stamped in. The literal is spread LAST, so a
8
+ * stray `type` inside a JavaScript caller's props object can never
9
+ * override it; TypeScript callers cannot pass one at all (`Props` omits
10
+ * it). The tree BLOCK builder is exported as `treeBlock`, keeping the
11
+ * `tree` name for the version-stamping root wrapper.
12
+ *
13
+ * `tree(root)` stamps {@link BLOCK_SET_VERSION} from the shared wire
14
+ * package — the version the SERVER validates against, which is why
15
+ * extensions import these builders from `@sigrun/sigrun/blocks` (the
16
+ * host-linked copy, spec §14.1) instead of bundling their own.
17
+ */
18
+ import type { Binding, Block, CalloutBlock, Card, ChartBlock, ChoiceField, CollectionBlock, ColumnsBlock, DateField, DaterangeField, DatetimeField, DetailsBlock, DiffBlock, FieldsBlock, FileField, FormBlock, GridBlock, ImageBlock, ListField, LogBlock, LongtextField, MarkdownBlock, MeterBlock, NumberField, PageBlock, SectionBlock, SecretField, StackBlock, StatBlock, TextField, ToggleField, Tree, TreeBlock } from "@sigrun/shared/blocks";
19
+ /** One block/field's props: the wire shape minus the `type` literal. */
20
+ type Props<T> = Omit<T, "type">;
21
+ /** `bind("/cards")` → `{ $: "/cards" }` — a pointer into the surface's data model (§4.2). */
22
+ export declare function bind(pointer: string): Binding;
23
+ /** Wrap a root block with the server's block-set version (§4.4). */
24
+ export declare function tree(root: Block): Tree;
25
+ export declare function page(props: Props<PageBlock>): PageBlock;
26
+ export declare function stack(props: Props<StackBlock>): StackBlock;
27
+ export declare function columns(props: Props<ColumnsBlock>): ColumnsBlock;
28
+ export declare function grid(props: Props<GridBlock>): GridBlock;
29
+ export declare function section(props: Props<SectionBlock>): SectionBlock;
30
+ export declare function markdown(props: Props<MarkdownBlock>): MarkdownBlock;
31
+ export declare function fields(props: Props<FieldsBlock>): FieldsBlock;
32
+ export declare function stat(props: Props<StatBlock>): StatBlock;
33
+ export declare function callout(props: Props<CalloutBlock>): CalloutBlock;
34
+ export declare function image(props: Props<ImageBlock>): ImageBlock;
35
+ export declare function chart(props: Props<ChartBlock>): ChartBlock;
36
+ export declare function meter(props: Props<MeterBlock>): MeterBlock;
37
+ export declare function log(props: Props<LogBlock>): LogBlock;
38
+ /** The tree BLOCK builder (§5.3) — `tree` is the root wrapper. */
39
+ export declare function treeBlock(props: Props<TreeBlock>): TreeBlock;
40
+ export declare function diff(props: Props<DiffBlock>): DiffBlock;
41
+ export declare function details(props: Props<DetailsBlock>): DetailsBlock;
42
+ export declare function collection(props: Props<CollectionBlock>): CollectionBlock;
43
+ /** The collection item template (§5.5.1) — a slot bundle, not a block. */
44
+ export declare function card(props: Card): Card;
45
+ export declare function form(props: Props<FormBlock>): FormBlock;
46
+ export declare function text(props: Props<TextField>): TextField;
47
+ export declare function longtext(props: Props<LongtextField>): LongtextField;
48
+ export declare function number(props: Props<NumberField>): NumberField;
49
+ export declare function toggle(props: Props<ToggleField>): ToggleField;
50
+ export declare function choice(props: Props<ChoiceField>): ChoiceField;
51
+ export declare function date(props: Props<DateField>): DateField;
52
+ export declare function datetime(props: Props<DatetimeField>): DatetimeField;
53
+ export declare function daterange(props: Props<DaterangeField>): DaterangeField;
54
+ export declare function secret(props: Props<SecretField>): SecretField;
55
+ export declare function file(props: Props<FileField>): FileField;
56
+ export declare function list(props: Props<ListField>): ListField;
57
+ export {};
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Image source rules (spec §11.1): `asset:` paths, `data:` URLs of the
3
+ * four accepted MIME types (capped at 192 KiB of base64 text), and
4
+ * `https:` URLs. SVG and AVIF are never accepted (script payload; no
5
+ * TUI decoder), and `http:` URLs are rejected. The proxy that rewrites
6
+ * and fetches is U7; this module only validates.
7
+ */
8
+ /** A source that passed the §11.1 rules. */
9
+ export type ImageSourceOk = {
10
+ ok: true;
11
+ kind: "asset" | "data" | "https";
12
+ };
13
+ /** A source the §11.1 rules reject. */
14
+ export type ImageSourceErr = {
15
+ ok: false;
16
+ message: string;
17
+ };
18
+ /** The result of checking one `src` value. */
19
+ export type ImageSourceResult = ImageSourceOk | ImageSourceErr;
20
+ /**
21
+ * Check one image `src` against the §11.1 source rules. Bindings
22
+ * (`{$: …}` objects) are not checked here — their resolved string is,
23
+ * where the data model holds one (the data-size limit already covers
24
+ * the byte cost).
25
+ */
26
+ export declare function checkImageSource(src: string): ImageSourceResult;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `@sigrun/sigrun/blocks` — the extension authoring surface for the
3
+ * block set (spec §14.1): the typed builders, the wire types, and
4
+ * `validateTree` (§14.2) so an extension can unit-test its trees
5
+ * against the same validation the server runs.
6
+ *
7
+ * Extensions import this subpath, never a bundled copy: the package is
8
+ * a `peerDependency: "*"` (see docs/extending.md) and
9
+ * `@sigrun/sigrun` is a HOST RUNTIME MODULE (src/harness/packages.ts)
10
+ * — the host links its own running copy into every extension packages
11
+ * dir, so `tree(...)` always stamps the server's
12
+ * {@link BLOCK_SET_VERSION}. The `exports` map points here at
13
+ * `dist/blocks/index.js` (never `src/`), which is what the built
14
+ * server ships.
15
+ */
16
+ export * from "./builders.js";
17
+ export { validateTree, validateData, VALID_PLACEMENTS, PLACEMENT_RULES, LIMITS, } from "./validate.js";
18
+ export type { PlacementLimits, PlacementRule, ValidateDataResult, ValidateErr, ValidateOk, ValidateOptions, ValidateResult, ValidationError, } from "./validate.js";
19
+ export { BLOCK_SET_VERSION } from "@sigrun/shared/blocks";
20
+ export type * from "@sigrun/shared/blocks";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The per-placement size limits of spec §4.5: max blocks per tree, and
3
+ * max UTF-8 bytes of the serialized JSON for the tree and the data.
4
+ *
5
+ * Over a limit the server rejects with `413 surface_too_large` naming
6
+ * the limit; `validateTree`/`validateData` surface the same error
7
+ * strings. A collection counts as one block however many items it
8
+ * binds, and a `data:` image URL counts toward whichever of the tree or
9
+ * the data holds it (the sizes are measured over the serialized JSON,
10
+ * so both hold by construction).
11
+ */
12
+ import type { Placement } from "@sigrun/shared/blocks";
13
+ /** One row of the §4.5 limits table. */
14
+ export interface PlacementLimits {
15
+ /** Max blocks in one tree (a collection counts as one). */
16
+ readonly blocks: number;
17
+ /** Max UTF-8 bytes of the serialized tree JSON. */
18
+ readonly treeBytes: number;
19
+ /** Max UTF-8 bytes of the serialized data JSON. */
20
+ readonly dataBytes: number;
21
+ }
22
+ /** Spec §4.5, one row per placement. */
23
+ export declare const LIMITS: Readonly<Record<Placement, PlacementLimits>>;
24
+ /** UTF-8 byte length of a value's serialized JSON (spec §4.5 sizing). */
25
+ export declare function jsonBytes(value: unknown): number;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The placement rules of spec §5.7, as data: which blocks, which
3
+ * collection displays and which form fields each placement allows,
4
+ * plus the root-block and `page.sidebar` constraints the table carries.
5
+ *
6
+ * `validateTree` walks a tree against these rules; the tests pin the
7
+ * table's literal cells so this data cannot drift from the spec.
8
+ */
9
+ /** One row of the §5.7 table. */
10
+ export interface PlacementRule {
11
+ /** Blocks allowed anywhere in the tree. */
12
+ readonly blocks: ReadonlySet<string>;
13
+ /** The only legal root block type, when the placement pins it. */
14
+ readonly rootBlock?: "page" | "form";
15
+ /** Whether a root `page` may carry `sidebar` (panel: no). */
16
+ readonly pageSidebar: boolean;
17
+ /** Collection displays allowed (`undefined`: all five). */
18
+ readonly collectionDisplays?: readonly string[];
19
+ /** Field types allowed in `form` blocks. */
20
+ readonly fields: ReadonlySet<string>;
21
+ }
22
+ /** The placement names, kept local so this module has no imports. */
23
+ type PlacementName = "view" | "panel" | "settings" | "chat" | "interaction" | "ambient";
24
+ /** Spec §5.7, one row per placement. */
25
+ export declare const PLACEMENT_RULES: Readonly<Record<PlacementName, PlacementRule>>;
26
+ export {};
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The typebox wire schemas for every block and field (spec §5/§6.1),
3
+ * each compiled once into its own `Validator` (spec §14.2 validation).
4
+ *
5
+ * Design notes:
6
+ * - Schemas are the wire contract, so they deliberately do NOT set
7
+ * `additionalProperties: false`: unknown props pass through and
8
+ * channels ignore them (the block-set version governs what exists).
9
+ * - `Tone`, `IconName` and `Format` are enums/patterns built from the
10
+ * shared constants; an unknown icon name fails here, at the icon
11
+ * prop's own path.
12
+ * - Bindable props are `Union([literal, Binding])` throughout; the
13
+ * literal-ness of STRUCTURAL props (`display`, `groups`, `reorder`,
14
+ * `mode`, field `name`s, `columns`, handler names) is enforced by the
15
+ * second walk in `validate.ts`, not the schema, so the error can say
16
+ * "must be a literal" instead of an anyOf mismatch.
17
+ * - Child slots (`children`, `sidebar`, `main`, `detail`,
18
+ * `columns[].block`, `collection.item`, `form` children, a field's
19
+ * `list.item`, `page.switcher`, a bulk action's `form.root`) are
20
+ * deliberately loose here (any object): `validate.ts` walks the tree
21
+ * and validates every child node against ITS compiled validator, so
22
+ * one broken block is one precise error instead of a union mismatch
23
+ * per sibling branch.
24
+ * - `Compile` (typebox/compile) builds each validator; its errors are
25
+ * read from `instancePath`, relative to the node the validator ran
26
+ * against.
27
+ */
28
+ import type { Validator } from "typebox/compile";
29
+ /** One compiled validator per block type, keyed by its `type` literal. */
30
+ export declare const BLOCK_VALIDATORS: ReadonlyMap<string, Validator>;
31
+ /** One compiled validator per field type, keyed by its `type` literal. */
32
+ export declare const FIELD_VALIDATORS: ReadonlyMap<string, Validator>;
33
+ /** The compiled validator for `Action` objects (spec §6.1). */
34
+ export declare const ACTION_VALIDATOR: Validator;
35
+ /** The compiled validator for action buttons (spec §6.1). */
36
+ export declare const ACTION_BUTTON_VALIDATOR: Validator;
37
+ /** The compiled validator for a collection's card template (spec §5.5.1). */
38
+ export declare const CARD_VALIDATOR: Validator;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `validateTree` (spec §14.2): every error with its JSON path and a
3
+ * plain message.
4
+ *
5
+ * Two passes:
6
+ * 1. The compiled typebox validators (`schema.ts`) check each node's
7
+ * shape: the walk descends the tree, finds every block, field,
8
+ * action, button and card template, and runs the matching compiled
9
+ * validator against it. Instance paths (relative to the node) join
10
+ * the walk's path, so one broken block is one precise error
11
+ * (`root.main[2].text`), never a union mismatch per sibling.
12
+ * 2. The same walk checks what schemas cannot express: placements
13
+ * (§5.7), the literal-ness of structural props (§4.2), handler names
14
+ * (§6.1), relative bindings outside item templates (§4.2), ids
15
+ * (§4.1), `turn` outside chat (§6.2), `open` schemes (§6.1), image
16
+ * sources (§11.1), limits (§4.5), and the ambient one-line rule
17
+ * (§9.2).
18
+ */
19
+ import { type Placement, type Tree } from "@sigrun/shared/blocks";
20
+ export { PLACEMENT_RULES } from "./placements.js";
21
+ export { LIMITS } from "./limits.js";
22
+ export type { PlacementRule } from "./placements.js";
23
+ export type { PlacementLimits } from "./limits.js";
24
+ /** The placements the block set defines (spec §3). */
25
+ export declare const VALID_PLACEMENTS: readonly Placement[];
26
+ /** One validation error: where and what (spec §14.2). */
27
+ export interface ValidationError {
28
+ path: string;
29
+ message: string;
30
+ }
31
+ /** Options for `validateTree`. */
32
+ export interface ValidateOptions {
33
+ placement: Placement;
34
+ /**
35
+ * The surface's `actions` map keys (spec §6.1); when given, every
36
+ * handler-name prop must be in it. When omitted (chat), handler
37
+ * names are free labels.
38
+ */
39
+ handlers?: ReadonlySet<string>;
40
+ /** The server's block-set version; a mismatched tree is rejected (§4.4). */
41
+ serverV?: number;
42
+ /** For ambient trees: which `place` renders this body (§9.2 one-line rule). */
43
+ ambientPlace?: "status" | "footer" | "panel";
44
+ }
45
+ /** The ok result of `validateTree`: the tree, narrowed to the wire type. */
46
+ export interface ValidateOk {
47
+ ok: true;
48
+ tree: Tree;
49
+ }
50
+ /** The failure result: every error found, each with its path. */
51
+ export interface ValidateErr {
52
+ ok: false;
53
+ errors: ValidationError[];
54
+ }
55
+ /** `validateTree`'s result. */
56
+ export type ValidateResult = ValidateOk | ValidateErr;
57
+ /** The result of `validateData`. */
58
+ export type ValidateDataResult = {
59
+ ok: true;
60
+ } | {
61
+ ok: false;
62
+ errors: ValidationError[];
63
+ };
64
+ /**
65
+ * Validate a block tree against a placement (spec §14.2). Returns every
66
+ * error with its JSON path; on success, the narrowed tree.
67
+ */
68
+ export declare function validateTree(tree: unknown, opts: ValidateOptions): ValidateResult;
69
+ /**
70
+ * Validate a surface's data object: a JSON object within the placement's
71
+ * data-size limit (spec §4.5).
72
+ */
73
+ export declare function validateData(data: unknown, placement: Placement): ValidateDataResult;
package/dist/cli.js CHANGED
@@ -47,7 +47,9 @@ import { dirname, join } from "node:path";
47
47
  import * as readline from "node:readline/promises";
48
48
  import { Writable } from "node:stream";
49
49
  import { resolveStoreRoots, writeFileAtomic } from "./harness/stores.js";
50
- import { openIdentityStores } from "./harness/identity/index.js";
50
+ import { openIdentityStores, AccountLinkConflictError } from "./harness/identity/index.js";
51
+ import { normalizeTypedUsername } from "./harness/identity/typed-username.js";
52
+ import { deriveUsername, foldSubject } from "@sigrun/shared/usernames";
51
53
  import { pathToFileURL } from "node:url";
52
54
  import { effectiveMethods, loadConfig } from "./config.js";
53
55
  import { probeIsolation, runHelperProbe, selfTestHelper } from "./sandbox/availability.js";
@@ -63,7 +65,9 @@ export const USAGE_TEXT = `usage: sigrun [--user <name>]
63
65
  sigrun token create <name> --user <user>
64
66
  sigrun token list
65
67
  sigrun token revoke <id>
66
- sigrun user add <user> [--admin] [--name <n>] [--email <e>]
68
+ sigrun user add [<user>] [--proxy <username>] [--admin] [--name <n>] [--email <e>]
69
+ sigrun user link <user> --proxy <username>
70
+ sigrun user unlink --proxy <username>
67
71
  sigrun user list
68
72
  sigrun provider list [--url <base>] [--token <token>]
69
73
  sigrun provider key <id> [--url <base>] [--token <token>]
@@ -134,8 +138,9 @@ function parseTokenArgs(argv) {
134
138
  throw usageError(`unknown token subcommand ${JSON.stringify(sub ?? "")} (create, list, revoke)`);
135
139
  }
136
140
  /**
137
- * Parse `user <sub> …` (Task 7): `add <user> [--admin] [--name <n>]
138
- * [--email <e>]`, `list`. One positional (the username), flags anywhere.
141
+ * Parse `user <sub> …`: `add [<user>] [--proxy <username>]`, explicit
142
+ * `link`/`unlink`, and `list`. A proxy add may omit the directory key,
143
+ * deriving it from the external username; ordinary add still requires it.
139
144
  */
140
145
  function parseUserArgs(argv) {
141
146
  const sub = argv[0];
@@ -145,51 +150,61 @@ function parseUserArgs(argv) {
145
150
  }
146
151
  return { command: "user", sub: "list" };
147
152
  }
148
- if (sub === "add") {
153
+ if (sub === "add" || sub === "link" || sub === "unlink") {
149
154
  let user;
155
+ let proxy;
150
156
  let admin = false;
151
157
  let name;
152
158
  let email;
153
159
  let index = 1;
154
160
  while (index < argv.length) {
155
161
  const arg = argv[index];
156
- if (arg === "--admin") {
162
+ if (sub === "add" && arg === "--admin") {
157
163
  admin = true;
158
164
  index += 1;
159
165
  continue;
160
166
  }
161
- if (arg === "--name" || arg === "--email") {
167
+ if (arg === "--proxy" || (sub === "add" && (arg === "--name" || arg === "--email"))) {
162
168
  const value = argv[index + 1];
163
169
  if (value === undefined || value.startsWith("--")) {
164
170
  throw usageError(`${arg} requires a value`);
165
171
  }
166
- if (arg === "--name")
172
+ if (arg === "--proxy")
173
+ proxy = value;
174
+ else if (arg === "--name")
167
175
  name = value;
168
176
  else
169
177
  email = value;
170
178
  index += 2;
171
179
  continue;
172
180
  }
173
- if (user === undefined && !arg.startsWith("--")) {
181
+ if (sub !== "unlink" && user === undefined && !arg.startsWith("--")) {
174
182
  user = arg;
175
183
  index += 1;
176
184
  continue;
177
185
  }
178
186
  throw usageError(`unexpected argument ${JSON.stringify(arg)}`);
179
187
  }
180
- if (user === undefined) {
181
- throw usageError("user add requires a <user>");
188
+ if (proxy === undefined) {
189
+ if (sub !== "add")
190
+ throw usageError(`user ${sub} requires --proxy <username>`);
191
+ if (user === undefined)
192
+ throw usageError("user add requires a <user>");
193
+ }
194
+ if (sub === "link" && user === undefined) {
195
+ throw usageError("user link requires a <user>");
182
196
  }
183
197
  return {
184
198
  command: "user",
185
- sub: "add",
186
- user,
199
+ sub,
200
+ ...(user === undefined ? {} : { user }),
201
+ ...(proxy === undefined ? {} : { proxy }),
187
202
  ...(admin ? { admin } : {}),
188
203
  ...(name === undefined ? {} : { name }),
189
204
  ...(email === undefined ? {} : { email }),
190
205
  };
191
206
  }
192
- throw usageError(`unknown user subcommand ${JSON.stringify(sub ?? "")} (add, list)`);
207
+ throw usageError(`unknown user subcommand ${JSON.stringify(sub ?? "")} (add, link, unlink, list)`);
193
208
  }
194
209
  /**
195
210
  * Parse `provider <sub> …` (release 0.1 Task 2b): `list`, `key <id>`,
@@ -506,9 +521,21 @@ export async function ttyPrompt(question, opts) {
506
521
  rl.close();
507
522
  }
508
523
  }
509
- /** Resolve the attach/run user: `--user` > `SIGRUN_USER` > tui.json. */
524
+ /**
525
+ * Resolve the attach/run user: `--user` > `SIGRUN_USER` > tui.json. The
526
+ * source is operator-typed (unchecked): derive the canonical key before
527
+ * it reaches the wire (the server derives on receipt too). Unusable
528
+ * input fails loud, never a silent empty identity.
529
+ */
510
530
  function resolveUser(parsed, env, file) {
511
- return parsed.user ?? env.SIGRUN_USER ?? file.user;
531
+ const raw = parsed.user ?? env.SIGRUN_USER ?? file.user;
532
+ if (raw === undefined || raw.trim() === "")
533
+ return undefined;
534
+ const derived = deriveUsername(raw);
535
+ if (derived === undefined) {
536
+ throw new Error(`invalid_username: "${raw}" derives no usable username`);
537
+ }
538
+ return derived;
512
539
  }
513
540
  /**
514
541
  * The tui.json UI refresh fields (C1) as a spreadable launch fragment —
@@ -654,7 +681,14 @@ async function runIdentityCommand(parsed, env, io, isolationProbe) {
654
681
  if (parsed.command === "token") {
655
682
  switch (parsed.sub) {
656
683
  case "create": {
657
- const user = parsed.user;
684
+ // A typed source, same rule as `user add`: the resulting key
685
+ // is what the token (and its printed secret) belongs to.
686
+ const raw = parsed.user;
687
+ const derived = deriveUsername(raw);
688
+ if (derived === undefined) {
689
+ throw new Error(`invalid_username: "${raw}" derives no usable username`);
690
+ }
691
+ const user = derived;
658
692
  if (stores.users.get(user) === undefined) {
659
693
  throw new Error(`unknown user "${user}" — add it first: sigrun user add ${user}`);
660
694
  }
@@ -685,6 +719,13 @@ async function runIdentityCommand(parsed, env, io, isolationProbe) {
685
719
  }
686
720
  switch (parsed.sub) {
687
721
  case "add": {
722
+ const input = parsed.user ?? parsed.proxy;
723
+ const typed = normalizeTypedUsername(input, (key) => stores.users.get(key) !== undefined);
724
+ if (!typed.ok) {
725
+ throw new Error(typed.error === "invalid_username"
726
+ ? `invalid_username: "${input}" derives no usable username`
727
+ : `username_taken: user "${parsed.user === undefined ? input : deriveUsername(input) ?? input}" already exists`);
728
+ }
688
729
  const config = loadConfig(env, new SettingsStore({ dir: defaultConfigDir(env) }).read());
689
730
  const coreRoot = selfPackageRoot();
690
731
  const isolation = await isolationProbe({
@@ -703,13 +744,53 @@ async function runIdentityCommand(parsed, env, io, isolationProbe) {
703
744
  proxySecretHeader: config.auth.proxy.secretHeader,
704
745
  }),
705
746
  });
706
- await stores.users.add({
707
- user: parsed.user,
747
+ const record = await stores.users.add({
748
+ user: typed.key,
708
749
  admin: parsed.admin ?? false,
709
750
  ...(parsed.name === undefined ? {} : { name: parsed.name }),
710
751
  ...(parsed.email === undefined ? {} : { email: parsed.email }),
711
752
  source: "cli",
712
753
  });
754
+ if (parsed.proxy !== undefined) {
755
+ const subject = foldSubject(parsed.proxy);
756
+ try {
757
+ await stores.accounts.link({
758
+ provider: "proxy",
759
+ subject,
760
+ user: record.user,
761
+ via: "admin",
762
+ });
763
+ }
764
+ catch (error) {
765
+ if (!(error instanceof AccountLinkConflictError))
766
+ throw error;
767
+ await stores.users.remove(record.user);
768
+ const holder = stores.accounts.lookup("proxy", subject)?.user;
769
+ throw new Error(`account_link_conflict: proxy identity is linked to ${holder ?? "another user"}; removed user "${record.user}"`);
770
+ }
771
+ }
772
+ io.out?.(record.user);
773
+ return 0;
774
+ }
775
+ case "link": {
776
+ const user = deriveUsername(parsed.user);
777
+ if (user === undefined || stores.users.get(user) === undefined) {
778
+ throw new Error(`unknown user "${parsed.user}"`);
779
+ }
780
+ const link = await stores.accounts.link({
781
+ provider: "proxy",
782
+ subject: foldSubject(parsed.proxy),
783
+ user,
784
+ via: "admin",
785
+ });
786
+ io.out?.(`${link.provider}:${link.subject}\t${link.user}`);
787
+ return 0;
788
+ }
789
+ case "unlink": {
790
+ const subject = foldSubject(parsed.proxy);
791
+ if (!(await stores.accounts.unlink("proxy", subject))) {
792
+ throw new Error(`no proxy link for "${subject}"`);
793
+ }
713
794
  return 0;
714
795
  }
715
796
  case "list": {
@@ -719,6 +800,8 @@ async function runIdentityCommand(parsed, env, io, isolationProbe) {
719
800
  record.user,
720
801
  ...(record.name === undefined ? [] : [record.name]),
721
802
  ...(record.email === undefined ? [] : [record.email]),
803
+ ...stores.accounts.listForUser(record.user)
804
+ .map((link) => `${link.provider}:${link.subject}`),
722
805
  ].join("\t"));
723
806
  }
724
807
  return 0;
@@ -727,6 +810,15 @@ async function runIdentityCommand(parsed, env, io, isolationProbe) {
727
810
  // parseArgs guarantees one of the cases above.
728
811
  throw new Error(`unhandled ${parsed.command} subcommand`);
729
812
  }
813
+ /**
814
+ * The CLI-local mirror of the server's proxy-only rule (proxy-trust
815
+ * Task 6): no token callers exist, so a setup-code prompt can never
816
+ * be redeemed. Kept here rather than imported from src/server — the
817
+ * CLI does not import server auth modules.
818
+ */
819
+ function isProxyOnly(methods) {
820
+ return methods.includes("proxy") && !methods.includes("token");
821
+ }
730
822
  /**
731
823
  * The pre-launch onboarding flow (Task 7, spec §5.8): `attach`/`run`
732
824
  * ask the server its state; an UNCLAIMED server gets the interactive
@@ -766,6 +858,12 @@ async function onboardIfUnclaimed(server, prompt, fetchImpl) {
766
858
  }
767
859
  if (state.state !== "unclaimed")
768
860
  return undefined;
861
+ // Proxy-trust Task 6: a proxy-only server without a vouched identity
862
+ // refuses the claim (403 proxy_identity_required) and never mints a
863
+ // setup code — prompt for one and the operator can only lose.
864
+ if (isProxyOnly(state.methods) && state.codeRequired && state.proxyIdentity === undefined) {
865
+ throw new Error("sigrun: this sigrun onboards through its sign-in proxy; open it in a browser");
866
+ }
769
867
  let code;
770
868
  if (state.codeRequired) {
771
869
  code = (await prompt("setup code: ")).trim();
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@ import { isIanaTimeZone } from "./jobs/spec.js";
2
2
  import { isAbsolute, join } from "node:path";
3
3
  import { resolveStoreRoots } from "./harness/stores.js";
4
4
  import { isLoopbackHost } from "./server/auth/loopback.js";
5
+ import { deriveUsername, isUsernameKey } from "@sigrun/shared/usernames";
5
6
  /**
6
7
  * Sigrun configuration.
7
8
  *
@@ -432,6 +433,15 @@ function parseAuth(env, server) {
432
433
  }
433
434
  const token = env.SIGRUN_API_TOKEN?.trim() || undefined;
434
435
  const secret = env.SIGRUN_PROXY_SECRET?.trim() || undefined;
436
+ // The service-token user is an operator-typed identity that no request
437
+ // ever re-derives: it must ALREADY be a canonical username key at load
438
+ // time (unlike the header/CLI sources, which derive). Anything else is
439
+ // a config error naming the derived suggestion.
440
+ const serviceTokenUser = env.SIGRUN_API_TOKEN_USER?.trim() || "sigrun";
441
+ if (token !== undefined && !isUsernameKey(serviceTokenUser)) {
442
+ throw new Error(`SIGRUN_API_TOKEN_USER must already be a username key, got "${serviceTokenUser}" — ` +
443
+ `did you mean "${deriveUsername(serviceTokenUser) ?? "sigrun"}"?`);
444
+ }
435
445
  return {
436
446
  sessionDays: server?.sessionDays ?? DEFAULT_AUTH.sessionDays,
437
447
  methods,
@@ -459,7 +469,7 @@ function parseAuth(env, server) {
459
469
  : {
460
470
  serviceToken: {
461
471
  token,
462
- user: env.SIGRUN_API_TOKEN_USER?.trim() || "sigrun",
472
+ user: serviceTokenUser,
463
473
  },
464
474
  }),
465
475
  };
@@ -556,8 +566,13 @@ export function parseBackupSections(raw) {
556
566
  function parseCompaction(env, server) {
557
567
  // Env parse first (a bad env value fails loud even when the file's
558
568
  // value is valid); a file field applies only where its env var is
559
- // absent; code defaults last.
569
+ // absent; code defaults last. `modelOverrides` is file-only (a map
570
+ // has no env spelling), passed through verbatim — pi-durable's
571
+ // `compactionPolicy` applies it per model at compaction time.
560
572
  return {
573
+ ...(server?.modelOverrides === undefined
574
+ ? {}
575
+ : { modelOverrides: server.modelOverrides }),
561
576
  enabled: parseCompactionEnabled(env) ??
562
577
  server?.enabled ??
563
578
  DEFAULT_COMPACTION.enabled,
@@ -831,6 +846,7 @@ export function resolvedServerSection(config) {
831
846
  * | `SIGRUN_PORT` | `port` |
832
847
  * | `SIGRUN_WORKSPACE_PATH` | `workspacePath` |
833
848
  * | `SIGRUN_STORAGE_PATH` | `storagePath` |
849
+ * | `SIGRUN_SKIP_MIGRATION_SNAPSHOT` | — (boot escape hatch; never persisted) |
834
850
  * | `SIGRUN_DATA_DIR` | `dataDir` |
835
851
  * | `SIGRUN_CACHE_DIR` | `cacheDir` |
836
852
  * | `SIGRUN_STATE_DIR` | `stateDir` |
@@ -994,6 +1010,9 @@ export function effectiveServerSection(env, fileServer, config) {
994
1010
  emit("compaction.reserveTokens", config.compaction.reserveTokens, ["compaction", "reserveTokens"]);
995
1011
  emit("compaction.keepRecentTokens", config.compaction.keepRecentTokens, ["compaction", "keepRecentTokens"]);
996
1012
  emit("compaction.backgroundTokens", config.compaction.backgroundTokens, ["compaction", "backgroundTokens"]);
1013
+ if (config.compaction.modelOverrides !== undefined) {
1014
+ emit("compaction.modelOverrides", config.compaction.modelOverrides, ["compaction", "modelOverrides"]);
1015
+ }
997
1016
  emit("memorySeam.hookDeadlineMs", config.memorySeam.hookDeadlineMs, ["memorySeam", "hookDeadlineMs"]);
998
1017
  emit("memorySeam.idleThresholdMs", config.memorySeam.idleThresholdMs, ["memorySeam", "idleThresholdMs"]);
999
1018
  emit("memorySeam.workerDeadlineMs", config.memorySeam.workerDeadlineMs, ["memorySeam", "workerDeadlineMs"]);