@sigrun/sigrun 0.6.1 → 0.7.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 (63) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +105 -18
  3. package/dist/blocks/build-stamp.json +2 -2
  4. package/dist/blocks/schema.js +5 -0
  5. package/dist/cli.js +85 -22
  6. package/dist/curation/host.js +46 -21
  7. package/dist/curation/migrate.js +96 -0
  8. package/dist/curation/overlays.js +52 -16
  9. package/dist/curation/paths.js +22 -0
  10. package/dist/curation/runs.js +34 -17
  11. package/dist/curation/skill-tool.js +34 -9
  12. package/dist/curation/store.js +382 -254
  13. package/dist/curation/targets.js +21 -0
  14. package/dist/curation/wrap.js +1 -6
  15. package/dist/extension.d.ts +12 -3
  16. package/dist/harness/boot.js +143 -6
  17. package/dist/harness/event-bus.js +19 -0
  18. package/dist/harness/loader.js +22 -0
  19. package/dist/harness/reserved-sections.js +2 -0
  20. package/dist/harness/seam-host.js +71 -16
  21. package/dist/harness/workspace.js +34 -1
  22. package/dist/history/git.js +707 -0
  23. package/dist/history/trailers.js +145 -0
  24. package/dist/history/workspace-history.js +47 -0
  25. package/dist/jobs/index.js +5 -3
  26. package/dist/jobs/reconcile.js +2 -3
  27. package/dist/jobs/state-dir.js +9 -0
  28. package/dist/jobs/transfer.js +6 -6
  29. package/dist/sandbox/grants.js +11 -0
  30. package/dist/sandbox/launch.js +19 -9
  31. package/dist/sandbox/policy.js +6 -1
  32. package/dist/serve.js +3 -0
  33. package/dist/server/auth/proxy-only.js +5 -4
  34. package/dist/server/core-views/index.js +26 -0
  35. package/dist/server/core-views/jobs.js +661 -0
  36. package/dist/server/core-views/skills-tree.js +628 -0
  37. package/dist/server/core-views/skills.js +1090 -0
  38. package/dist/server/index.js +88 -0
  39. package/dist/server/jobs-access.js +62 -0
  40. package/dist/server/notifications.js +24 -12
  41. package/dist/server/routes/extensions.js +9 -1
  42. package/dist/server/routes/jobs.js +19 -52
  43. package/dist/server/routes/meta.js +9 -0
  44. package/dist/server/routes/onboarding.js +13 -1
  45. package/dist/server/surfaces/service.js +38 -0
  46. package/dist/skills/index.js +1 -1
  47. package/dist/skills/roots.js +301 -0
  48. package/node_modules/@sigrun/shared/CHANGELOG.md +8 -0
  49. package/node_modules/@sigrun/shared/dist/auth.d.ts +22 -0
  50. package/node_modules/@sigrun/shared/dist/auth.js +26 -0
  51. package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +1 -1
  52. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +15 -0
  53. package/node_modules/@sigrun/shared/dist/model-label.d.ts +29 -0
  54. package/node_modules/@sigrun/shared/dist/model-label.js +55 -0
  55. package/node_modules/@sigrun/shared/dist/views.d.ts +28 -0
  56. package/node_modules/@sigrun/shared/package.json +9 -1
  57. package/package.json +7 -7
  58. package/webui/dist/assets/index-BI5k-Oci.js +55 -0
  59. package/webui/dist/assets/index-EGluyRXk.css +1 -0
  60. package/webui/dist/favicon.svg +7 -5
  61. package/webui/dist/index.html +3 -3
  62. package/webui/dist/assets/index-BR0di0OG.css +0 -1
  63. package/webui/dist/assets/index-BzrA1T4g.js +0 -55
@@ -34,6 +34,7 @@ import Fastify from "fastify";
34
34
  import { join } from "node:path";
35
35
  import { LoginFlows } from "./login/flows.js";
36
36
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
37
+ import { SessionMetaDoc } from "./session-meta.js";
37
38
  import { buildCommandCatalog, SLASH_COMMANDS } from "./commands.js";
38
39
  import { effectiveMethods } from "../config.js";
39
40
  import { registerAuthPipeline } from "./auth/pipeline.js";
@@ -41,6 +42,12 @@ import { isLoopbackHost } from "./auth/loopback.js";
41
42
  import { SseRegistry } from "./sse-registry.js";
42
43
  import { storeAgentChange } from "./helpers.js";
43
44
  import { registerCoreOwnershipParticipant } from "./ownership.js";
45
+ import { createJobsView } from "./core-views/jobs.js";
46
+ import { createSkillsView } from "./core-views/skills.js";
47
+ import { learnedSkillsDir } from "../curation/paths.js";
48
+ import { primaryAdmin } from "../harness/identity/primary-admin.js";
49
+ import { isSuperuser } from "./auth/principal.js";
50
+ import { accessPrincipalOf } from "./auth/access-principal.js";
44
51
  import { createHostBridge } from "./host-bridge.js";
45
52
  import { registerJobsOwnershipParticipant } from "../jobs/transfer.js";
46
53
  import { registerAdminRoutes } from "./routes/admin.js";
@@ -108,6 +115,13 @@ import { resolveWebuiBundle, serveWebuiBundle, } from "./static.js";
108
115
  * env-less boot).
109
116
  */
110
117
  export async function createServer(opened, config, contributedBundles = [], options) {
118
+ // Polish A1 (spec §5.2): a server EXISTS here, so every seam host's
119
+ // bridge is expected — synchronously, before the first await, so a
120
+ // pre-bind call made while this function opens keeps the bounded wait
121
+ // instead of failing fast. Rigs that start a server need no open
122
+ // option; `serve`/`webui/e2e` additionally pass `expectHostBridge:
123
+ // true` at open for the window BEFORE this call.
124
+ opened.expectHostBridge();
111
125
  const app = Fastify({
112
126
  logger: false,
113
127
  // U7 Task 3: the image route's GET `src=data:` form carries up to
@@ -273,6 +287,44 @@ export async function createServer(opened, config, contributedBundles = [], opti
273
287
  })),
274
288
  },
275
289
  ]);
290
+ // The core-owned views (tabs+skills Tasks 4/5/12): the reserved `core`
291
+ // slug's `SurfaceDef<"view">` list — the built-in Jobs view (Task 5)
292
+ // and the built-in Skills view (Task 12). The jobs view shares the
293
+ // routes' access rule (`jobs-access.ts`) and the routes' principal
294
+ // projection (accessPrincipalOf over the live directory), so the
295
+ // view and `GET /v1/jobs` always agree on which rows a caller sees.
296
+ // The skills view resolves every root per CALLER (their workspace +
297
+ // learned repos, the deployment's config dir and package roots) and
298
+ // bumps every rendered instance after every write.
299
+ surfacesService.attachCoreViews([
300
+ createJobsView({
301
+ jobs: opened.jobs,
302
+ now: () => new Date(),
303
+ accessPrincipal: (principal) => accessPrincipalOf(principal, opened.identity.users),
304
+ }),
305
+ createSkillsView({
306
+ curationOf: (user) => opened.curationOf(user),
307
+ workspaceRoot: (user) => opened.workspaceRootOf(user),
308
+ workspaceHistoryFor: (user) => opened.history.workspace(user),
309
+ learnedDirOf: (user) => {
310
+ if (opened.identity.users.get(user) === undefined)
311
+ return undefined;
312
+ return learnedSkillsDir(join(opened.extensionStores.roots.dataDir, "users", user));
313
+ },
314
+ userDir: join(opened.extensionStores.roots.configDir, "skills"),
315
+ packageRoots: () => opened.skillRoots,
316
+ bump: () => surfacesService.bumpAllInstances("core", "skills"),
317
+ // Whole-branch review I3: the userless superuser (the naive
318
+ // single-operator principal) acts as the PRIMARY ADMIN — the
319
+ // identity package's own seam (the jail's fallback). Absent (an
320
+ // empty directory), the superuser keeps its own scope. An
321
+ // anonymous NON-superuser never resolves (access none).
322
+ resolveUserlessCaller: (principal) => principal.user ??
323
+ (isSuperuser(principal)
324
+ ? primaryAdmin(opened.identity.users.list())?.user
325
+ : undefined),
326
+ }),
327
+ ]);
276
328
  app.decorate("surfaces", surfacesService);
277
329
  // The chat-block change seam (UI-blocks U5 Task 2, spec §8.2):
278
330
  // show_blocks/update_blocks AND the user-edit route publish
@@ -342,6 +394,42 @@ export async function createServer(opened, config, contributedBundles = [], opti
342
394
  // the first event) and never stopped — the surface rides the server
343
395
  // lifetime, like the routes above.
344
396
  startNotifications(ctx);
397
+ // The jobs view's freshness (tabs+skills Task 5): every `job.changed`
398
+ // host event bumps every rendered `core/jobs` instance's rev through
399
+ // the bus's bounded side lane (the hub contract — no work inline);
400
+ // watching clients refetch the data document (the tree is static).
401
+ opened.events.subscribe((event) => {
402
+ if (event.type !== "job.changed")
403
+ return;
404
+ void opened.eventBus.runBounded("core/jobs job.changed", () => {
405
+ surfacesService.bumpAllInstances("core", "jobs");
406
+ });
407
+ });
408
+ // Workspace skills history (tabs+skills Task 10): every settled run
409
+ // catches up the conversation owner's `<workspace>/skills` repo on the
410
+ // bus's bounded side lane. Every error is caught and logged — the
411
+ // settle hook NEVER throws into the event bus — and ownerless
412
+ // conversations (no SessionMeta owner) do nothing.
413
+ opened.events.subscribe((event) => {
414
+ if (event.type !== "run.settled")
415
+ return;
416
+ void opened.eventBus.runBounded("history run.settled", async () => {
417
+ try {
418
+ const meta = await opened.harness.snapshot(SessionMetaDoc, event.conversationId, BACKGROUND_CONTEXT);
419
+ const owner = meta?.owner;
420
+ if (owner === undefined)
421
+ return;
422
+ await opened.history.workspace(owner)?.catchUp({
423
+ actor: { kind: "agent" },
424
+ summary: "agent changes",
425
+ conversationId: event.conversationId,
426
+ });
427
+ }
428
+ catch (error) {
429
+ console.warn(`workspace history catch-up failed: ${String(error)}`);
430
+ }
431
+ });
432
+ });
345
433
  // The pipeline is registered FIRST (W1 Task 3): it replaces the naive
346
434
  // `X-Sigrun-User` hook — Host/Origin checks, the public surface, the
347
435
  // env service token, the setup gate, and method resolution stamp
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The §5.9 job access rule, shared (tabs+skills Task 5): moved OUT of
3
+ * `routes/jobs.ts` unchanged in behaviour so the REST routes and the
4
+ * `core/jobs` view (`core-views/jobs.ts`) resolve the SAME verdicts —
5
+ * hidden is absence/404 everywhere, never a 403 tell; a read-only
6
+ * caller on a write path is the one 403.
7
+ *
8
+ * Two shapes, exactly the routes' old inline logic:
9
+ *
10
+ * - {@link jobAccess} — the access verdict for one LIVE row (the
11
+ * record's owner/sharing through `accessOf`);
12
+ * - {@link resolveJob} — one job resolved for the access rule: the
13
+ * live record's row when it exists; for a RETIRED job (the history
14
+ * routes' case) the newest revision's shape, which answers until
15
+ * retention trims the records. `undefined` only when neither exists.
16
+ */
17
+ import { accessOf } from "@sigrun/shared/access";
18
+ /** The §5.9 resource form ({owner?, sharing?}) of a job row. */
19
+ function resourceOf(row) {
20
+ return {
21
+ ...(row.owner === undefined ? {} : { owner: row.owner }),
22
+ ...(row.sharing === undefined ? {} : { sharing: row.sharing }),
23
+ };
24
+ }
25
+ /** The caller's access to one LIVE job row (§5.9 `accessOf`). */
26
+ export function jobAccess(principal, row) {
27
+ return accessOf(principal, resourceOf(row));
28
+ }
29
+ /**
30
+ * Resolve one job for the access rule: the live record's row when it
31
+ * exists; for a RETIRED job the newest revision's shape (access is
32
+ * evaluated against that revision's owner/sharing — the record is gone
33
+ * with the retire). `undefined` only when neither exists (unknown id).
34
+ */
35
+ export async function resolveJob(jobs, principal, id) {
36
+ const wire = await jobs.get(id);
37
+ if (wire !== undefined) {
38
+ const { access: _ignored, ...row } = wire;
39
+ return { row, access: accessOf(principal, resourceOf(row)) };
40
+ }
41
+ // Retired: the newest revision answers until retention trims it.
42
+ const [newest] = await jobs.revisions(id);
43
+ if (newest === undefined)
44
+ return undefined;
45
+ const row = {
46
+ id,
47
+ name: newest.spec.name,
48
+ source: newest.source,
49
+ ...(newest.owner === undefined ? {} : { owner: newest.owner }),
50
+ ...(newest.sharing === undefined ? {} : { sharing: newest.sharing }),
51
+ schedule: newest.spec.schedule,
52
+ action: newest.spec.action,
53
+ enabled: newest.enabled,
54
+ version: newest.version,
55
+ };
56
+ return {
57
+ // The retired row's status went with the record; the nudge only
58
+ // needs identity + definition (clients refetch the list).
59
+ row: { ...row, state: "disabled" },
60
+ access: accessOf(principal, resourceOf(row)),
61
+ };
62
+ }
@@ -84,20 +84,32 @@ export function startNotifications(ctx) {
84
84
  ? `${config.publicUrl}/#/c/${base.conversationId}`
85
85
  : undefined;
86
86
  for (const user of recipients) {
87
- const event = {
88
- type: base.type,
89
- conversationId: base.conversationId,
90
- jobId: base.jobId,
91
- ...(base.callId === undefined ? {} : { callId: base.callId }),
87
+ // Absent optional fields are OMITTED, never null/undefined —
88
+ // the conditional spreads keep the wire shape exact (callId
89
+ // rides ONLY interaction.pending; outcome ONLY run.settled).
90
+ // Build per-variant so the per-recipient event is a complete
91
+ // NotificationEvent with NO widening cast.
92
+ const shared = {
93
+ ...(base.conversationId === undefined
94
+ ? {}
95
+ : { conversationId: base.conversationId }),
96
+ ...(base.jobId === undefined ? {} : { jobId: base.jobId }),
92
97
  user,
93
98
  title: base.title,
94
99
  body: base.body,
95
100
  ...(url === undefined ? {} : { url }),
96
- // The settled outcome rides ONLY run.settled events — the
97
- // other variants never gain the field (the type is the union;
98
- // the conditional spread keeps the wire shape exact).
99
- ...(base.outcome === undefined ? {} : { outcome: base.outcome }),
100
101
  };
102
+ const event = base.type === "interaction.pending"
103
+ ? {
104
+ type: "interaction.pending",
105
+ ...shared,
106
+ ...(base.callId === undefined
107
+ ? {}
108
+ : { callId: base.callId }),
109
+ }
110
+ : base.type === "job.failed"
111
+ ? { type: "job.failed", ...shared }
112
+ : { type: "run.settled", ...shared, outcome: base.outcome };
101
113
  deliverEvent(ctx, targets, event);
102
114
  }
103
115
  };
@@ -157,10 +169,10 @@ export function startNotifications(ctx) {
157
169
  // second answer read, no inferred status). An unanswered run never
158
170
  // titles itself "Reply ready": the reason is the run's own, with
159
171
  // the bare status as the fallback for a reason-less settle.
172
+ const reason_ = reason ?? "unanswered";
160
173
  const settledOutcome = outcome === "answered"
161
174
  ? { status: "answered" }
162
- : { status: "unanswered", reason: reason ?? "unanswered" };
163
- const settledReason = reason ?? "unanswered";
175
+ : { status: "unanswered", reason: reason_ };
164
176
  deliver({
165
177
  type: "run.settled",
166
178
  conversationId,
@@ -171,7 +183,7 @@ export function startNotifications(ctx) {
171
183
  : "Run ended without a reply",
172
184
  body: outcome === "answered"
173
185
  ? displayName(meta, conversationId)
174
- : `${displayName(meta, conversationId)} — ${settledReason}`,
186
+ : `${displayName(meta, conversationId)} — ${reason_}`,
175
187
  outcome: settledOutcome,
176
188
  }, [meta?.owner]);
177
189
  };
@@ -116,7 +116,15 @@ export function registerExtensionRoutes(app, ctx) {
116
116
  routes: [],
117
117
  });
118
118
  }
119
- return { extensions };
119
+ // The core-owned views (tabs+skills Task 4): the reserved `core`
120
+ // slug's view defs under `coreViews` — core views are NOT
121
+ // extensions, so no `extensions[]` row exists for them (the health
122
+ // dot is unaffected). Absent when none attach (sparse).
123
+ const coreViews = ctx.surfaces.coreViews();
124
+ return {
125
+ extensions,
126
+ ...(coreViews.length === 0 ? {} : { coreViews }),
127
+ };
120
128
  });
121
129
  // The per-extension settings object (`settings/<slug>.json`), absent
122
130
  // file → `{}`. GET is ungated beyond the pipeline: settings values are
@@ -3,14 +3,15 @@
3
3
  * Task 3's JobsService.
4
4
  *
5
5
  * ACCESS (§5.9, the conversations rule applied to job records): every
6
- * route resolves `accessOf(principal, {owner, sharing})` against the
7
- * job RECORD — hidden is 404 `job_not_found` on every `:id` route and
8
- * absence from the list (no 403 tell); a read-only caller on a write
9
- * route gets 403. The owner is server-stamped at create
10
- * (`principal.user`; a body-carried `owner` is stripped) and moves only
11
- * through the transfer path; `sharing` is the PATCH share surface
12
- * (mirrored onto the record from the merged spec, exactly like a
13
- * declared job).
6
+ * route resolves the shared rule (`jobs-access.ts` — moved out of this
7
+ * module for tabs+skills Task 5 so the `core/jobs` view resolves the
8
+ * SAME verdicts) against the job RECORD — hidden is 404
9
+ * `job_not_found` on every `:id` route and absence from the list (no
10
+ * 403 tell); a read-only caller on a write route gets 403. The owner is
11
+ * server-stamped at create (`principal.user`; a body-carried `owner` is
12
+ * stripped) and moves only through the transfer path; `sharing` is the
13
+ * PATCH share surface (mirrored onto the record from the merged spec,
14
+ * exactly like a declared job).
14
15
  *
15
16
  * The HISTORY routes (`runs`/`revisions`) answer for a RETIRED job too,
16
17
  * until retention trims its records — access is evaluated against the
@@ -21,17 +22,10 @@
21
22
  * return 409 `job_declared` (edit the source instead). Run-now honors
22
23
  * the caller's write access and the job's own `overlap`.
23
24
  */
24
- import { accessOf } from "@sigrun/shared/access";
25
25
  import { accessPrincipalOf } from "../auth/access-principal.js";
26
+ import { jobAccess, resolveJob, } from "../jobs-access.js";
26
27
  import { JobDeclaredError, JobNotFoundError } from "../../jobs/index.js";
27
28
  import { validateJobSpec } from "../../jobs/spec.js";
28
- /** The §5.9 resource form ({owner?, sharing?}) of a job row. */
29
- function resourceOf(row) {
30
- return {
31
- ...(row.owner === undefined ? {} : { owner: row.owner }),
32
- ...(row.sharing === undefined ? {} : { sharing: row.sharing }),
33
- };
34
- }
35
29
  /**
36
30
  * The shared `:id` error mapping: unknown/hidden → 404 `job_not_found`;
37
31
  * visible-but-readonly on a write route → 403 (the callers resolve
@@ -59,39 +53,12 @@ export function registerJobRoutes(app, ctx) {
59
53
  const jobs = opened.jobs;
60
54
  const accessPrincipal = (request) => accessPrincipalOf(request.principal, opened.identity.users);
61
55
  /**
62
- * Resolve one job for the access rule: the live record's row when it
63
- * exists; for a RETIRED job (the history routes' case) the newest
64
- * revision's shape. `undefined` only when neither exists (unknown id).
56
+ * Resolve one job for the access rule (the shared seam): the live
57
+ * record's row when it exists; for a RETIRED job (the history
58
+ * routes' case) the newest revision's shape. `undefined` only when
59
+ * neither exists (unknown id).
65
60
  */
66
- const resolve = async (request, id) => {
67
- const principal = accessPrincipal(request);
68
- const wire = await jobs.get(id);
69
- if (wire !== undefined) {
70
- const { access: _ignored, ...row } = wire;
71
- return { row, access: accessOf(principal, resourceOf(row)) };
72
- }
73
- // Retired: the newest revision answers until retention trims it.
74
- const [newest] = await jobs.revisions(id);
75
- if (newest === undefined)
76
- return undefined;
77
- const row = {
78
- id,
79
- name: newest.spec.name,
80
- source: newest.source,
81
- ...(newest.owner === undefined ? {} : { owner: newest.owner }),
82
- ...(newest.sharing === undefined ? {} : { sharing: newest.sharing }),
83
- schedule: newest.spec.schedule,
84
- action: newest.spec.action,
85
- enabled: newest.enabled,
86
- version: newest.version,
87
- };
88
- return {
89
- // The retired row's status went with the record; the nudge only
90
- // needs identity + definition (clients refetch the list).
91
- row: { ...row, state: "disabled" },
92
- access: accessOf(principal, resourceOf(row)),
93
- };
94
- };
61
+ const resolve = async (request, id) => await resolveJob(jobs, accessPrincipal(request), id);
95
62
  /** The read guard: unknown/hidden → the 404; the row otherwise. */
96
63
  const visible = async (request, reply, id) => {
97
64
  const resolved = await resolve(request, id);
@@ -106,7 +73,7 @@ export function registerJobRoutes(app, ctx) {
106
73
  const principal = accessPrincipal(request);
107
74
  const visibleJobs = [];
108
75
  for (const row of await jobs.list()) {
109
- const access = accessOf(principal, resourceOf(row));
76
+ const access = jobAccess(principal, row);
110
77
  if (access === "none")
111
78
  continue;
112
79
  visibleJobs.push({ ...row, access });
@@ -120,7 +87,7 @@ export function registerJobRoutes(app, ctx) {
120
87
  if (wire === undefined) {
121
88
  return await deny(reply, "job_not_found");
122
89
  }
123
- const access = accessOf(accessPrincipal(request), resourceOf(wire));
90
+ const access = jobAccess(accessPrincipal(request), wire);
124
91
  if (access === "none") {
125
92
  return await deny(reply, "job_not_found");
126
93
  }
@@ -222,7 +189,7 @@ export function registerJobRoutes(app, ctx) {
222
189
  if (wire === undefined) {
223
190
  return await deny(reply, "job_not_found");
224
191
  }
225
- const access = accessOf(accessPrincipal(request), resourceOf(wire));
192
+ const access = jobAccess(accessPrincipal(request), wire);
226
193
  if (access === "none") {
227
194
  return await deny(reply, "job_not_found");
228
195
  }
@@ -253,7 +220,7 @@ export function registerJobRoutes(app, ctx) {
253
220
  if (wire === undefined) {
254
221
  return await deny(reply, "job_not_found");
255
222
  }
256
- const access = accessOf(accessPrincipal(request), resourceOf(wire));
223
+ const access = jobAccess(accessPrincipal(request), wire);
257
224
  if (access === "none") {
258
225
  return await deny(reply, "job_not_found");
259
226
  }
@@ -139,6 +139,15 @@ export function registerMetaRoutes(app, ctx) {
139
139
  : { reason: opened.isolation.reason }),
140
140
  osUsers: opened.osUsers.status,
141
141
  },
142
+ // The core-owned views (tabs+skills Task 4): the reserved `core`
143
+ // slug's view defs, projected per request from the surface service
144
+ // (the SAME list /v1/extensions serves) — the tab shell's rail
145
+ // reads them from here and never fetches /v1/extensions separately.
146
+ // Absent when no core views attached (sparse — Task 4 boots with an
147
+ // empty list, Tasks 5/12 attach the real views).
148
+ ...(ctx.surfaces.coreViews().length === 0
149
+ ? {}
150
+ : { coreViews: ctx.surfaces.coreViews() }),
142
151
  // The seam inventory: declaration-derived, no doc reads.
143
152
  seam: {
144
153
  interactions: [...opened.seamKinds]
@@ -111,6 +111,15 @@ function resolveProxyIdentity(request, ctx) {
111
111
  ...(email === undefined ? {} : { email }),
112
112
  };
113
113
  }
114
+ /** The accepted claim-body keys, in the validator's switch order. */
115
+ const CLAIM_BODY_FIELDS = [
116
+ "code",
117
+ "user",
118
+ "name",
119
+ "email",
120
+ "issueToken",
121
+ "tokenName",
122
+ ];
114
123
  /**
115
124
  * Validate the claim body strictly: unknown keys and wrong types are
116
125
  * client errors (the repo's fail-loud validator posture).
@@ -141,7 +150,10 @@ function validateClaimBody(body) {
141
150
  result.issueToken = value;
142
151
  break;
143
152
  default:
144
- return { error: "invalid_field", field: key };
153
+ // Name BOTH directions: the sent key (field) and the accepted
154
+ // set (expected) — a `username` guess must point at `user`,
155
+ // never leave the operator to guess (fresh-eyes A3).
156
+ return { error: "invalid_field", field: key, expected: CLAIM_BODY_FIELDS };
145
157
  }
146
158
  }
147
159
  return result;
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import { rewriteImages } from "../images/rewrite.js";
28
28
  import { validateTree } from "../../blocks/validate.js";
29
+ import { coreViewInfos } from "../core-views/index.js";
29
30
  import { AccessCache } from "./access-cache.js";
30
31
  import { ActionDedup } from "./dedup.js";
31
32
  import { eventKey, instanceKey as defaultInstanceKey } from "./instance.js";
@@ -148,6 +149,43 @@ export class SurfaceService {
148
149
  this.publishChanged = options?.publishChanged;
149
150
  this.imageSign = options?.imageSign;
150
151
  }
152
+ /**
153
+ * Attach the core-owned views (tabs+skills Task 4): registered under
154
+ * the reserved `core` slug through the same `extra` lookup the
155
+ * settings surfaces use, so core views render/data/act through the
156
+ * ONE code path extension surfaces do. Their defs are retained for
157
+ * the `/v1/meta` + `/v1/extensions` `coreViews` projection (core
158
+ * views are NOT extensions — no `extensions[]` row, so the health
159
+ * dot is unaffected).
160
+ */
161
+ attachCoreViews(defs) {
162
+ this.extra.push({ extension: "core", defs });
163
+ this.coreViewDefs.push(...defs);
164
+ }
165
+ /** The attached core-view defs (attachCoreViews order). */
166
+ coreViewDefs = [];
167
+ /**
168
+ * The `coreViews` wire rows (tabs+skills Task 4): id/title/icon (+
169
+ * refreshMs) per attached core-view def.
170
+ */
171
+ coreViews() {
172
+ return coreViewInfos(this.coreViewDefs);
173
+ }
174
+ /**
175
+ * Bump every KNOWN instance of one surface (tabs+skills Task 4): the
176
+ * instance table's keys are `slug\u0001id\u0001key`, so a prefix scan
177
+ * finds every rendered instance; each gets a full {@link bumpRev}
178
+ * (rev advance + `surface.changed` publication). A surface with NO
179
+ * rendered instances is a no-op (nothing watches it).
180
+ */
181
+ bumpAllInstances(slug, id) {
182
+ const prefix = `${slug}\u0001${id}\u0001`;
183
+ for (const composite of this.instances.keys()) {
184
+ if (!composite.startsWith(prefix))
185
+ continue;
186
+ this.bumpRev(slug, id, composite.slice(prefix.length));
187
+ }
188
+ }
151
189
  /**
152
190
  * Attach the settings surfaces post-construction (their save
153
191
  * handlers close over THIS service for the optimistic-concurrency
@@ -111,7 +111,7 @@ function unquote(value) {
111
111
  * closing delimiter. Returns `undefined` when the block is absent or never
112
112
  * closes (neither required field could have parsed).
113
113
  */
114
- function parseSkillFile(text) {
114
+ export function parseSkillFile(text) {
115
115
  const lines = text.split(/\r?\n/);
116
116
  if (lines[0]?.trim() !== FRONTMATTER_DELIMITER)
117
117
  return undefined;