@sigrun/sigrun 0.0.0-stage → 0.1.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 +204 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +90 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The §3 health contract (spec §3): a surface whose implementation can fail
|
|
3
|
+
* at runtime exposes a health producer; core aggregates the producers
|
|
4
|
+
* retained at boot into the §5 meta `health` block (Task 9e).
|
|
5
|
+
*
|
|
6
|
+
* Producers are SYNCHRONOUS status reads — the meta route calls them per
|
|
7
|
+
* request, so the route itself never touches the network or storage. In v1
|
|
8
|
+
* memory backends (through the extension factory result) and provider
|
|
9
|
+
* contributors implement the contract; a degenerate implementation reports
|
|
10
|
+
* `"ok"`, and BY CONVENTION the absence of a producer means the same — a
|
|
11
|
+
* degenerate surface need not synthesize a constant producer.
|
|
12
|
+
*
|
|
13
|
+
* SPLIT (build-and-webui Task 1): the `HealthStatus` union's canonical
|
|
14
|
+
* declaration lives in the `@sigrun/shared` package
|
|
15
|
+
* (`shared/src/views.ts` — it rides the §5 `meta.health` wire); this
|
|
16
|
+
* module owns the producer contract and aggregation runtime, re-exporting
|
|
17
|
+
* the union for server-side importers.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Aggregate §3 producers for the §5 `health.providers` field: ANY producer
|
|
21
|
+
* reporting `"degraded"` degrades the surface; no producers — or all ok —
|
|
22
|
+
* is `"ok"` (degenerate surfaces report ok by absence, spec §3).
|
|
23
|
+
*/
|
|
24
|
+
export function aggregateHealth(producers) {
|
|
25
|
+
for (const producer of producers) {
|
|
26
|
+
if (producer.health() === "degraded")
|
|
27
|
+
return "degraded";
|
|
28
|
+
}
|
|
29
|
+
return "ok";
|
|
30
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity stores bundle (core hardening W1 Task 2): the directory,
|
|
3
|
+
* tokens, invites, and the setup code, opened over the resolved store
|
|
4
|
+
* roots — the object `openHarness` exposes as `OpenedHarness.identity`
|
|
5
|
+
* and the auth pipeline/routes consume.
|
|
6
|
+
*
|
|
7
|
+
- `<configDir>/users.json` — the user directory (0600; holds emails);
|
|
8
|
+
- `<configDir>/api-tokens.json` — the token digests (0600);
|
|
9
|
+
- `<stateDir>/invites.json` — the invite digests (0600);
|
|
10
|
+
- `<cacheDir>` — the setup code's home (pod-local, never backed up).
|
|
11
|
+
*
|
|
12
|
+
All four files: atomic writes (temp + rename), mtime-reload reads per
|
|
13
|
+
access (the CLI is a second writer), serialized mutations per store.
|
|
14
|
+
*/
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { UserDirectory } from "./users.js";
|
|
17
|
+
import { TokenStore, generateToken, hashToken, } from "./tokens.js";
|
|
18
|
+
import { InviteStore, DEFAULT_INVITE_EXPIRY_MS, } from "./invites.js";
|
|
19
|
+
import { ensureSetupCode, readSetupCode, clearSetupCode, generateCode, setupCodePath, CODE_ALPHABET, } from "./setup-code.js";
|
|
20
|
+
export { UserDirectory, TokenStore, generateToken, hashToken, InviteStore, DEFAULT_INVITE_EXPIRY_MS, ensureSetupCode, readSetupCode, clearSetupCode, generateCode, setupCodePath, CODE_ALPHABET, };
|
|
21
|
+
/** The file names each store owns under its root. */
|
|
22
|
+
const USERS_FILE = "users.json";
|
|
23
|
+
const TOKENS_FILE = "api-tokens.json";
|
|
24
|
+
const INVITES_FILE = "invites.json";
|
|
25
|
+
/**
|
|
26
|
+
* Open the identity stores over resolved roots (cheap — no I/O; every
|
|
27
|
+
* file is created lazily by its first write). Called by boot after
|
|
28
|
+
* `ensureStoreRoots`, and by tests directly over tmp dirs.
|
|
29
|
+
*/
|
|
30
|
+
export function openIdentityStores(roots) {
|
|
31
|
+
return {
|
|
32
|
+
users: new UserDirectory(join(roots.configDir, USERS_FILE)),
|
|
33
|
+
tokens: new TokenStore(join(roots.configDir, TOKENS_FILE)),
|
|
34
|
+
invites: new InviteStore(join(roots.stateDir, INVITES_FILE)),
|
|
35
|
+
cacheDir: roots.cacheDir,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The invite store (core hardening W1 Task 2, spec §5.8): ONE
|
|
3
|
+
* `invites.json` under the state root — admin-issued single-use codes
|
|
4
|
+
* that admit one directory user each.
|
|
5
|
+
*
|
|
6
|
+
- codes are the setup-code alphabet shape (`XXXX-XXXX`) but UNPINNED
|
|
7
|
+
by any env var — every `create` draws a fresh one;
|
|
8
|
+
- only the SHA-256 digest is stored (the same `hashToken` discipline
|
|
9
|
+
as tokens — a leaked file admits no one);
|
|
10
|
+
- single-use: a redeemed row keeps its `usedBy` FOREVER (Review
|
|
11
|
+
Focus 4) — an expired redeem returns undefined without touching the
|
|
12
|
+
row, and a used row past its expiry stays used, never re-openable;
|
|
13
|
+
- expiry compares epoch-ms `expiresAt` against the (injectable) now,
|
|
14
|
+
so clock-skewed cases are testable;
|
|
15
|
+
- mode `0600`, atomic writes, mtime-reload reads, mutating calls
|
|
16
|
+
serialized on the store's {@link MutationQueue} — two concurrent
|
|
17
|
+
redeems of one code yield exactly one success.
|
|
18
|
+
*
|
|
19
|
+
* The SETUP CODE is deliberately not a row here: it lives under the
|
|
20
|
+
* cache root (never a backup section) and is compared directly. It is
|
|
21
|
+
* the first invite only as a behavioral equivalence — it admits one
|
|
22
|
+
* admin claim.
|
|
23
|
+
*/
|
|
24
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
25
|
+
import { generateCode } from "./setup-code.js";
|
|
26
|
+
import { hashToken } from "./tokens.js";
|
|
27
|
+
import { MutationQueue, readRecords, writeJson } from "./json-store.js";
|
|
28
|
+
/** Default invite expiry: 7 days in ms (spec §5.8). */
|
|
29
|
+
export const DEFAULT_INVITE_EXPIRY_MS = 604_800_000;
|
|
30
|
+
/** Constant-time digest equality (delegates to the token discipline). */
|
|
31
|
+
function matchesCode(record, digest) {
|
|
32
|
+
if (record.sha256.length !== digest.length)
|
|
33
|
+
return false;
|
|
34
|
+
return timingSafeEqual(Buffer.from(record.sha256, "hex"), Buffer.from(digest, "hex"));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The file-backed invite store. `redeem` is the single mutator; both
|
|
38
|
+
* methods serialize on the store's {@link MutationQueue}.
|
|
39
|
+
*/
|
|
40
|
+
export class InviteStore {
|
|
41
|
+
path;
|
|
42
|
+
mutations = new MutationQueue();
|
|
43
|
+
constructor(path) {
|
|
44
|
+
this.path = path;
|
|
45
|
+
}
|
|
46
|
+
list() {
|
|
47
|
+
return readRecords(this.path);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Issue one invite: draws a fresh code, stores only its digest plus
|
|
51
|
+
* the grant and expiry (`expiresInMs` defaults to 7 days), and
|
|
52
|
+
* returns `{id, code, expiresAt}` — the code is shown once, by the
|
|
53
|
+
* caller; the expiry rides along so the issuing route serves the
|
|
54
|
+
* wire's `expiresAt` from the stored row, not a recomputed clock.
|
|
55
|
+
*/
|
|
56
|
+
create(opts) {
|
|
57
|
+
return this.mutations.run(async () => {
|
|
58
|
+
const code = generateCode();
|
|
59
|
+
const record = {
|
|
60
|
+
id: randomUUID(),
|
|
61
|
+
sha256: hashToken(code),
|
|
62
|
+
admin: opts.admin,
|
|
63
|
+
expiresAt: Date.now() + (opts.expiresInMs ?? DEFAULT_INVITE_EXPIRY_MS),
|
|
64
|
+
};
|
|
65
|
+
await writeJson(this.path, [...this.list(), record]);
|
|
66
|
+
return { id: record.id, code, expiresAt: record.expiresAt };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Redeem one code for `user`: undefined when no record matches the
|
|
71
|
+
* digest, when the row is already used (single-use, forever), or
|
|
72
|
+
* when `now` is past `expiresAt` — and in every undefined case the
|
|
73
|
+
* row is left untouched (an expired-but-unused invite is not marked,
|
|
74
|
+
* a used row keeps its `usedBy` past expiry). A success stamps
|
|
75
|
+
* `usedBy` atomically and returns the grant.
|
|
76
|
+
*/
|
|
77
|
+
redeem(code, user, now = Date.now()) {
|
|
78
|
+
return this.mutations.run(async () => {
|
|
79
|
+
const records = this.list();
|
|
80
|
+
const digest = hashToken(code);
|
|
81
|
+
const index = records.findIndex((record) => matchesCode(record, digest));
|
|
82
|
+
const record = index === -1 ? undefined : records[index];
|
|
83
|
+
if (record === undefined)
|
|
84
|
+
return undefined;
|
|
85
|
+
if (record.usedBy !== undefined)
|
|
86
|
+
return undefined;
|
|
87
|
+
if (now > record.expiresAt)
|
|
88
|
+
return undefined;
|
|
89
|
+
records[index] = { ...record, usedBy: user };
|
|
90
|
+
await writeJson(this.path, records);
|
|
91
|
+
return { admin: record.admin };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mtime-reloading JSON file helper behind every identity store
|
|
3
|
+
* (core hardening W1 Task 2, spec §5.4 "Store semantics"): identity
|
|
4
|
+
* files are small, mutated by whole-file read-modify-write, and shared
|
|
5
|
+
* with the CLI — a SEPARATE process — so:
|
|
6
|
+
*
|
|
7
|
+
- reads are SYNCHRONOUS and reload whenever the file's
|
|
8
|
+
`(mtimeMs, size)` fingerprint changes, checked per access (a CLI
|
|
9
|
+
revoke or user add is honored live, no restart);
|
|
10
|
+
- writes go through {@link writeFileAtomic} (temp file + rename, mode
|
|
11
|
+
`0600`), so a concurrent reader sees the old complete file or the
|
|
12
|
+
new complete file — never a half-written one (Review Focus 5).
|
|
13
|
+
*
|
|
14
|
+
* The read cache is module-wide and keyed by path: store instances on
|
|
15
|
+
* the same path share one entry, validated against the file's CURRENT
|
|
16
|
+
* stat per access, so a write through one instance is immediately
|
|
17
|
+
* visible through another. Cross-process, the same stat check is the
|
|
18
|
+
* contract (mtime granularity + size; a same-ms same-size clobber is
|
|
19
|
+
* the accepted last-writer-wins at household scale).
|
|
20
|
+
*
|
|
21
|
+
* A malformed file (unparseable JSON, or a non-array where a record
|
|
22
|
+
* array is expected) fails LOUD, naming the path — the
|
|
23
|
+
* `readJsonObject` posture: never a silent reset of identity data.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync, statSync } from "node:fs";
|
|
26
|
+
import { writeFileAtomic } from "../stores.js";
|
|
27
|
+
/**
|
|
28
|
+
* path → the last parse and the stat it was read under. Module-wide
|
|
29
|
+
* (never per-store): two store instances over one file are one cache.
|
|
30
|
+
*/
|
|
31
|
+
const cache = new Map();
|
|
32
|
+
/**
|
|
33
|
+
* Read one JSON file, reloading when its stat fingerprint changed since
|
|
34
|
+
* the last read of this path (checked on EVERY call). Absent file →
|
|
35
|
+
* `fallback` (first use is not an error); the absent state is uncached
|
|
36
|
+
* so the file's creation is seen by the very next read. Unparseable
|
|
37
|
+
* JSON fails loud, naming the path (never a silent reset of identity
|
|
38
|
+
* data).
|
|
39
|
+
*/
|
|
40
|
+
export function readJson(path, fallback) {
|
|
41
|
+
let stat;
|
|
42
|
+
try {
|
|
43
|
+
stat = statSync(path);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code === "ENOENT") {
|
|
47
|
+
cache.delete(path);
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
const cached = cache.get(path);
|
|
53
|
+
if (cached !== undefined &&
|
|
54
|
+
cached.mtimeMs === stat.mtimeMs &&
|
|
55
|
+
cached.size === stat.size) {
|
|
56
|
+
return cached.value;
|
|
57
|
+
}
|
|
58
|
+
let value;
|
|
59
|
+
try {
|
|
60
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw new Error(`sigrun: malformed JSON in ${path}: ${String(error)}`, { cause: error });
|
|
64
|
+
}
|
|
65
|
+
cache.set(path, { mtimeMs: stat.mtimeMs, size: stat.size, value });
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Read a record-array store file: absent → `[]`; anything but a JSON
|
|
70
|
+
* array is a malformed store, loud with the path named.
|
|
71
|
+
*/
|
|
72
|
+
export function readRecords(path) {
|
|
73
|
+
const value = readJson(path, []);
|
|
74
|
+
if (!Array.isArray(value)) {
|
|
75
|
+
throw new Error(`sigrun: ${path} must contain a JSON array of records`);
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Write one JSON value the house way (tabs, trailing newline),
|
|
81
|
+
* atomically, mode `0600` — identity files all carry credential
|
|
82
|
+
* material (token hashes, emails, invite hashes). The path's cache
|
|
83
|
+
* entry is invalidated (never eagerly re-statted): the next read
|
|
84
|
+
* re-stats and sees the new file.
|
|
85
|
+
*/
|
|
86
|
+
export async function writeJson(path, value) {
|
|
87
|
+
await writeFileAtomic(path, `${JSON.stringify(value, null, "\t")}\n`, 0o600);
|
|
88
|
+
cache.delete(path);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* A per-store-instance FIFO mutex (the "store semantics" serialization
|
|
92
|
+
* rule): every mutating method chains on one tail promise, so
|
|
93
|
+
* concurrent calls run strictly one-at-a-time and each sees the prior
|
|
94
|
+
* one's write — two concurrent redeems of one invite code yield
|
|
95
|
+
* exactly one success. A rejected operation never poisons the tail.
|
|
96
|
+
* Two instances over one path each serialize their OWN calls; across
|
|
97
|
+
* instances (and processes) atomic rename keeps files whole, with
|
|
98
|
+
* last-writer-wins the accepted resolution.
|
|
99
|
+
*/
|
|
100
|
+
export class MutationQueue {
|
|
101
|
+
tail = Promise.resolve();
|
|
102
|
+
/** Chain `op` on the tail; resolves/rejects with op's own outcome. */
|
|
103
|
+
run(op) {
|
|
104
|
+
const next = this.tail.then(op, op);
|
|
105
|
+
this.tail = next.then(() => undefined, () => undefined);
|
|
106
|
+
return next;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The setup code (core hardening W1 Task 2, spec §5.8): the one-shot
|
|
3
|
+
* onboarding credential an unclaimed server accepts — `XXXX-XXXX`,
|
|
4
|
+
* eight characters from a 32-char unambiguous alphabet (no `I`/`O`/`0`/
|
|
5
|
+
* `1` — readable over a pod log or a phone).
|
|
6
|
+
*
|
|
7
|
+
- pinned by `SIGRUN_SETUP_CODE` (env only — the k8s Secret shape) or
|
|
8
|
+
generated fresh at each unclaimed boot;
|
|
9
|
+
- written to `<cacheDir>/setup-code`, mode `0600`, atomically — the
|
|
10
|
+
CACHE root is pod-local and never a backup section, so the code
|
|
11
|
+
never lands in a backup, while `kubectl exec … cat` retrieves it;
|
|
12
|
+
- the cache root is deliberately NOT assumed to exist (the wiped-
|
|
13
|
+
`emptyDir` posture, stores.ts Review Focus 5): every write recreates
|
|
14
|
+
it at call time;
|
|
15
|
+
- deleted at claim (`clearSetupCode`), idempotently.
|
|
16
|
+
*
|
|
17
|
+
* The code is NOT an `InviteStore` row (see invites.ts): it is compared
|
|
18
|
+
* directly by the onboarding route — "the first invite" is a behavioral
|
|
19
|
+
* equivalence, not a storage fact.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
23
|
+
import { randomInt } from "node:crypto";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { writeFileAtomic } from "../stores.js";
|
|
26
|
+
/** The unambiguous alphabet (spec §5.8): 32 chars, no `I`/`O`/`0`/`1`. */
|
|
27
|
+
export const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
28
|
+
/** The code file name under the cache root. */
|
|
29
|
+
const SETUP_CODE_FILE = "setup-code";
|
|
30
|
+
/** The env var that pins the code (k8s Secret shape; env only). */
|
|
31
|
+
const SETUP_CODE_ENV = "SIGRUN_SETUP_CODE";
|
|
32
|
+
/** `<cacheDir>/setup-code`. */
|
|
33
|
+
export function setupCodePath(cacheDir) {
|
|
34
|
+
return join(cacheDir, SETUP_CODE_FILE);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Draw one code: `XXXX-XXXX`, 8 characters from {@link CODE_ALPHABET}
|
|
38
|
+
* (`randomInt` — unbiased over the 32-symbol space). Invites reuse this
|
|
39
|
+
* draw for their code shape.
|
|
40
|
+
*/
|
|
41
|
+
export function generateCode() {
|
|
42
|
+
const pick = () => CODE_ALPHABET.charAt(randomInt(CODE_ALPHABET.length));
|
|
43
|
+
return `${pick()}${pick()}${pick()}${pick()}-${pick()}${pick()}${pick()}${pick()}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Ensure the setup code exists and return it: the `SIGRUN_SETUP_CODE`
|
|
47
|
+
* pin wins (trimmed; an empty value counts as unset, the module-wide
|
|
48
|
+
* ""-means-unset reading), else a fresh code is drawn. The code is
|
|
49
|
+
* (re)written to `<cacheDir>/setup-code` mode `0600` atomically every
|
|
50
|
+
* call — each unclaimed boot refreshes the file, and a pinned code
|
|
51
|
+
* self-heals a wiped cache root.
|
|
52
|
+
*/
|
|
53
|
+
export async function ensureSetupCode(cacheDir, env) {
|
|
54
|
+
const pinned = env[SETUP_CODE_ENV];
|
|
55
|
+
const code = pinned !== undefined && pinned !== "" ? pinned.trim() : generateCode();
|
|
56
|
+
await mkdir(cacheDir, { recursive: true });
|
|
57
|
+
await writeFileAtomic(setupCodePath(cacheDir), `${code}\n`, 0o600);
|
|
58
|
+
return code;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Read the persisted code; absent file → undefined. Whitespace is
|
|
62
|
+
* trimmed (the file carries one trailing newline); an empty file reads
|
|
63
|
+
* as undefined.
|
|
64
|
+
*/
|
|
65
|
+
export function readSetupCode(cacheDir) {
|
|
66
|
+
let text;
|
|
67
|
+
try {
|
|
68
|
+
text = readFileSync(setupCodePath(cacheDir), "utf8");
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code === "ENOENT")
|
|
72
|
+
return undefined;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
const code = text.trim();
|
|
76
|
+
return code === "" ? undefined : code;
|
|
77
|
+
}
|
|
78
|
+
/** Delete the code file (the claim step); absent file is a no-op. */
|
|
79
|
+
export async function clearSetupCode(cacheDir) {
|
|
80
|
+
await rm(setupCodePath(cacheDir), { force: true });
|
|
81
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API token store (core hardening W1 Task 2, spec §5.4): ONE
|
|
3
|
+
* `api-tokens.json` at the config root — the bearer-credential surface
|
|
4
|
+
* for TUI `attach`, scripts, and browser login.
|
|
5
|
+
*
|
|
6
|
+
- tokens are `sgr_` + base64url(32 random bytes) — shown ONCE at
|
|
7
|
+
create, never persisted;
|
|
8
|
+
- only the SHA-256 hex digest is stored (`sha256`), and verification
|
|
9
|
+
compares digests with `timingSafeEqual` on equal-length buffers;
|
|
10
|
+
- mode `0600`, atomic writes, mtime-reload reads (the CLI's
|
|
11
|
+
`sigrun token` subcommands are a second writer, honored live);
|
|
12
|
+
- `user` names the directory user whose rights the token carries —
|
|
13
|
+
admin is a property of the user, never of the token.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual, } from "node:crypto";
|
|
16
|
+
import { MutationQueue, readRecords, writeJson } from "./json-store.js";
|
|
17
|
+
/** The token wire prefix (the `sk-`/`sgr_` convention; spec §5.4). */
|
|
18
|
+
const TOKEN_PREFIX = "sgr_";
|
|
19
|
+
/** A fresh token: `sgr_` + 32 random bytes in base64url (43 chars). */
|
|
20
|
+
export function generateToken() {
|
|
21
|
+
return `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
|
|
22
|
+
}
|
|
23
|
+
/** The stored form of a token: its SHA-256 hex digest. */
|
|
24
|
+
export function hashToken(token) {
|
|
25
|
+
return createHash("sha256").update(token).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
/** Constant-time digest equality (equal-length buffers, spec §5.4). */
|
|
28
|
+
function sameDigest(stored, digest) {
|
|
29
|
+
if (stored.length !== digest.length)
|
|
30
|
+
return false;
|
|
31
|
+
return timingSafeEqual(Buffer.from(stored, "hex"), Buffer.from(digest, "hex"));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The file-backed token store. Reads/verifications are synchronous
|
|
35
|
+
* (mtime reload per access); creates/revokes serialize on the store's
|
|
36
|
+
* {@link MutationQueue}.
|
|
37
|
+
*/
|
|
38
|
+
export class TokenStore {
|
|
39
|
+
path;
|
|
40
|
+
mutations = new MutationQueue();
|
|
41
|
+
constructor(path) {
|
|
42
|
+
this.path = path;
|
|
43
|
+
}
|
|
44
|
+
/** All records, or one user's with `user` given (no secrets exist to omit). */
|
|
45
|
+
list(user) {
|
|
46
|
+
const records = readRecords(this.path);
|
|
47
|
+
return user === undefined
|
|
48
|
+
? records
|
|
49
|
+
: records.filter((record) => record.user === user);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Verify a presented token: hash it, compare against every stored
|
|
53
|
+
* digest with `timingSafeEqual`. Absent/unknown/malformed → undefined.
|
|
54
|
+
*/
|
|
55
|
+
verify(token) {
|
|
56
|
+
const digest = hashToken(token);
|
|
57
|
+
return this.list().find((record) => sameDigest(record.sha256, digest));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Mint one token for `user`: generates the secret, stores ONLY its
|
|
61
|
+
* digest, and returns both — the token is shown once, by the caller.
|
|
62
|
+
*/
|
|
63
|
+
create(name, user) {
|
|
64
|
+
return this.mutations.run(async () => {
|
|
65
|
+
const token = generateToken();
|
|
66
|
+
const record = {
|
|
67
|
+
id: randomUUID(),
|
|
68
|
+
name,
|
|
69
|
+
user,
|
|
70
|
+
sha256: hashToken(token),
|
|
71
|
+
createdAt: Date.now(),
|
|
72
|
+
};
|
|
73
|
+
await writeJson(this.path, [...this.list(), record]);
|
|
74
|
+
return { record, token };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** Revoke by record id; false when no such record existed. */
|
|
78
|
+
revoke(id) {
|
|
79
|
+
return this.mutations.run(async () => {
|
|
80
|
+
const records = this.list();
|
|
81
|
+
const index = records.findIndex((record) => record.id === id);
|
|
82
|
+
if (index === -1)
|
|
83
|
+
return false;
|
|
84
|
+
records.splice(index, 1);
|
|
85
|
+
await writeJson(this.path, records);
|
|
86
|
+
return true;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** Revoke every token of one user (user removal's companion); returns the count. */
|
|
90
|
+
revokeAllFor(user) {
|
|
91
|
+
return this.mutations.run(async () => {
|
|
92
|
+
const records = this.list();
|
|
93
|
+
const kept = records.filter((record) => record.user !== user);
|
|
94
|
+
const removed = records.length - kept.length;
|
|
95
|
+
if (removed > 0) {
|
|
96
|
+
await writeJson(this.path, kept);
|
|
97
|
+
}
|
|
98
|
+
return removed;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user directory (core hardening W1 Task 2, spec §5.8): ONE
|
|
3
|
+
* `users.json` at the config root — the identity source for owner
|
|
4
|
+
* stamping, share pickers, and admin checks. Mode `0600` (it holds
|
|
5
|
+
* emails), atomic writes, mtime-reload reads (the shared json-store
|
|
6
|
+
* discipline — the CLI is a second writer, honored live).
|
|
7
|
+
*
|
|
8
|
+
* `user` matches `^[a-z0-9][a-z0-9._-]*$` and is IMMUTABLE — the
|
|
9
|
+
* identity other records key on; renames are delete + add by design.
|
|
10
|
+
* The server is unclaimed while the directory is empty and claimed
|
|
11
|
+
* after the first add (`isClaimed`); `admin` is a property of the USER
|
|
12
|
+
* (tokens carry their user's rights — never their own admin flag).
|
|
13
|
+
*/
|
|
14
|
+
import { MutationQueue, readRecords, writeJson } from "./json-store.js";
|
|
15
|
+
/** The username rule (spec §5.8): lowercase-first, then `[a-z0-9._-]`. */
|
|
16
|
+
const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
17
|
+
/**
|
|
18
|
+
* The file-backed user directory. Reads are synchronous (mtime reload
|
|
19
|
+
* per access); every mutation is a whole-file read-modify-write that
|
|
20
|
+
* serializes on the store's {@link MutationQueue}.
|
|
21
|
+
*/
|
|
22
|
+
export class UserDirectory {
|
|
23
|
+
path;
|
|
24
|
+
mutations = new MutationQueue();
|
|
25
|
+
constructor(path) {
|
|
26
|
+
this.path = path;
|
|
27
|
+
}
|
|
28
|
+
/** Every user, insertion order (the first add is the onboarding claim). */
|
|
29
|
+
list() {
|
|
30
|
+
return readRecords(this.path);
|
|
31
|
+
}
|
|
32
|
+
/** One user by exact username; absent → undefined. */
|
|
33
|
+
get(user) {
|
|
34
|
+
return this.list().find((record) => record.user === user);
|
|
35
|
+
}
|
|
36
|
+
/** True once the first user exists — the server's claimed state. */
|
|
37
|
+
isClaimed() {
|
|
38
|
+
return this.list().length > 0;
|
|
39
|
+
}
|
|
40
|
+
/** How many users carry `admin` (invite-issuing route guards read it). */
|
|
41
|
+
recordAdminCount() {
|
|
42
|
+
return this.list().filter((record) => record.admin).length;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Add one user (spec §5.8): rejects an invalid username and a
|
|
46
|
+
* duplicate, both loud with the username named. `createdAt` is
|
|
47
|
+
* stamped here, never by the caller.
|
|
48
|
+
*/
|
|
49
|
+
add(user) {
|
|
50
|
+
return this.mutations.run(async () => {
|
|
51
|
+
if (!USERNAME_PATTERN.test(user.user)) {
|
|
52
|
+
throw new Error(`invalid username "${user.user}": must match ^[a-z0-9][a-z0-9._-]*$`);
|
|
53
|
+
}
|
|
54
|
+
const records = this.list();
|
|
55
|
+
if (records.some((record) => record.user === user.user)) {
|
|
56
|
+
throw new Error(`user "${user.user}" already exists`);
|
|
57
|
+
}
|
|
58
|
+
const record = { ...user, createdAt: Date.now() };
|
|
59
|
+
await writeJson(this.path, [...records, record]);
|
|
60
|
+
return record;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Patch one user's mutable fields (`name`/`email`/`admin` — the
|
|
65
|
+
* username itself is immutable). Absent user throws; the file is
|
|
66
|
+
* rewritten atomically.
|
|
67
|
+
*/
|
|
68
|
+
update(user, patch) {
|
|
69
|
+
return this.mutations.run(async () => {
|
|
70
|
+
const records = this.list();
|
|
71
|
+
const index = records.findIndex((record) => record.user === user);
|
|
72
|
+
if (index === -1) {
|
|
73
|
+
throw new Error(`unknown user "${user}"`);
|
|
74
|
+
}
|
|
75
|
+
const updated = { ...records[index], ...patch };
|
|
76
|
+
records[index] = updated;
|
|
77
|
+
await writeJson(this.path, records);
|
|
78
|
+
return updated;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** Remove one user (their tokens are NOT revoked here — caller's job). */
|
|
82
|
+
remove(user) {
|
|
83
|
+
return this.mutations.run(async () => {
|
|
84
|
+
const records = this.list();
|
|
85
|
+
const index = records.findIndex((record) => record.user === user);
|
|
86
|
+
if (index === -1) {
|
|
87
|
+
throw new Error(`unknown user "${user}"`);
|
|
88
|
+
}
|
|
89
|
+
records.splice(index, 1);
|
|
90
|
+
await writeJson(this.path, records);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|