@sigrun/sigrun 0.1.6 → 0.2.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 (126) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +352 -36
  3. package/dist/blocks/schema.js +9 -2
  4. package/dist/blocks/validate.js +57 -8
  5. package/dist/cli.js +26 -2
  6. package/dist/config.js +67 -1
  7. package/dist/extension.d.ts +189 -15
  8. package/dist/harness/ambient.js +39 -185
  9. package/dist/harness/blocks/entries.js +47 -0
  10. package/dist/harness/blocks/state.js +58 -0
  11. package/dist/harness/blocks/tools.js +274 -0
  12. package/dist/harness/boot.js +253 -20
  13. package/dist/harness/builtins.js +15 -11
  14. package/dist/harness/event-bus.js +105 -10
  15. package/dist/harness/identity/accounts.js +186 -0
  16. package/dist/harness/identity/groups.js +14 -0
  17. package/dist/harness/identity/index.js +13 -6
  18. package/dist/harness/identity/json-store.js +15 -4
  19. package/dist/harness/identity/setup-code-check.js +9 -0
  20. package/dist/harness/identity/tokens.js +28 -5
  21. package/dist/harness/identity/users.js +111 -12
  22. package/dist/harness/interactions.js +292 -136
  23. package/dist/harness/loader.js +271 -17
  24. package/dist/harness/packages.js +1 -1
  25. package/dist/harness/reserved-sections.js +21 -0
  26. package/dist/harness/seam-host.js +80 -6
  27. package/dist/harness/settings.js +63 -2
  28. package/dist/harness/stores.js +241 -4
  29. package/dist/harness/user-services.js +391 -0
  30. package/dist/harness/user-tools.js +187 -0
  31. package/dist/harness/workspace.js +61 -57
  32. package/dist/jobs/fire.js +10 -16
  33. package/dist/sandbox/availability.js +162 -0
  34. package/dist/sandbox/command-observer.js +59 -0
  35. package/dist/sandbox/grants.js +159 -0
  36. package/dist/sandbox/helper.js +29 -0
  37. package/dist/sandbox/isolation.js +29 -0
  38. package/dist/sandbox/launch.js +20 -0
  39. package/dist/sandbox/os-account.js +49 -0
  40. package/dist/sandbox/policy.js +253 -0
  41. package/dist/sandbox/pool.js +548 -0
  42. package/dist/sandbox/protocol.js +167 -0
  43. package/dist/sandbox/sandboxed-env.js +245 -0
  44. package/dist/sandbox/unavailable-env.js +49 -0
  45. package/dist/sandbox/worker.js +281 -0
  46. package/dist/serve.js +82 -7
  47. package/dist/server/auth/access-principal.js +16 -0
  48. package/dist/server/auth/pipeline.js +47 -33
  49. package/dist/server/blocks/answers.js +94 -0
  50. package/dist/server/blocks/chat-surfaces.js +393 -0
  51. package/dist/server/blocks/notes.js +216 -0
  52. package/dist/server/blocks/summaries.js +328 -0
  53. package/dist/server/commands.js +5 -0
  54. package/dist/server/content-parts-lazy.js +13 -0
  55. package/dist/server/conversation-handler.js +2 -5
  56. package/dist/server/conversation-ops.js +138 -37
  57. package/dist/server/helpers.js +18 -6
  58. package/dist/server/host-bridge.js +40 -20
  59. package/dist/server/images/cache.js +205 -0
  60. package/dist/server/images/convert.js +13 -0
  61. package/dist/server/images/fetch.js +302 -0
  62. package/dist/server/images/ranges.js +42 -0
  63. package/dist/server/images/rewrite.js +94 -0
  64. package/dist/server/images/sign.js +84 -0
  65. package/dist/server/images/sniff.js +62 -0
  66. package/dist/server/index.js +144 -4
  67. package/dist/server/login/flows.js +126 -0
  68. package/dist/server/login/http.js +31 -0
  69. package/dist/server/login/resolve.js +133 -0
  70. package/dist/server/login/setup-claim.js +55 -0
  71. package/dist/server/login/usernames.js +15 -0
  72. package/dist/server/ownership.js +13 -0
  73. package/dist/server/routes/admin.js +58 -0
  74. package/dist/server/routes/auth.js +88 -2
  75. package/dist/server/routes/conversations.js +3 -6
  76. package/dist/server/routes/events.js +28 -3
  77. package/dist/server/routes/extensions.js +37 -5
  78. package/dist/server/routes/images.js +267 -0
  79. package/dist/server/routes/interactions.js +57 -68
  80. package/dist/server/routes/jobs.js +2 -8
  81. package/dist/server/routes/login.js +223 -0
  82. package/dist/server/routes/me.js +90 -0
  83. package/dist/server/routes/meta.js +15 -0
  84. package/dist/server/routes/onboarding.js +94 -84
  85. package/dist/server/routes/settings-sections.js +190 -0
  86. package/dist/server/routes/settings.js +16 -4
  87. package/dist/server/routes/submissions.js +1 -1
  88. package/dist/server/routes/surfaces.js +175 -23
  89. package/dist/server/routes/users.js +116 -20
  90. package/dist/server/schema-fixture.js +213 -0
  91. package/dist/server/session-meta.js +3 -0
  92. package/dist/server/shutdown.js +11 -4
  93. package/dist/server/static.js +18 -0
  94. package/dist/server/surfaces/dedup.js +9 -2
  95. package/dist/server/surfaces/service.js +181 -13
  96. package/dist/server/surfaces/settings-surfaces.js +597 -0
  97. package/dist/server/turns/submit-turn.js +26 -0
  98. package/dist/server/views.js +16 -0
  99. package/dist/settings/core-sections.js +532 -0
  100. package/dist/settings/pending.js +181 -0
  101. package/dist/settings/registry.js +453 -0
  102. package/dist/skills/index.js +6 -0
  103. package/node_modules/@sigrun/shared/CHANGELOG.md +6 -0
  104. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  105. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  106. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  107. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  108. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  109. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  110. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  111. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  112. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  113. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  114. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  115. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  116. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  117. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  118. package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
  119. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  120. package/node_modules/@sigrun/shared/package.json +1 -1
  121. package/package.json +11 -6
  122. package/webui/dist/assets/index-CxB969qp.js +55 -0
  123. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  124. package/webui/dist/index.html +2 -2
  125. package/webui/dist/assets/index-CiYInlLx.css +0 -1
  126. package/webui/dist/assets/index-JDfOQV9z.js +0 -52
@@ -444,6 +444,65 @@ export const VIEW_SCHEMA = {
444
444
  }
445
445
  }
446
446
  },
447
+ {
448
+ "kind": "object",
449
+ "fields": {
450
+ "name": {
451
+ "kind": "string"
452
+ },
453
+ "description": {
454
+ "kind": "string"
455
+ },
456
+ "parameters": {
457
+ "kind": "object",
458
+ "fields": {
459
+ "type": {
460
+ "kind": "string"
461
+ },
462
+ "required": {
463
+ "kind": "array",
464
+ "variants": [
465
+ {
466
+ "kind": "string"
467
+ }
468
+ ]
469
+ },
470
+ "properties": {
471
+ "kind": "object",
472
+ "fields": {
473
+ "title": {
474
+ "kind": "object",
475
+ "fields": {
476
+ "type": {
477
+ "kind": "string"
478
+ },
479
+ "description": {
480
+ "kind": "string"
481
+ }
482
+ }
483
+ },
484
+ "tree": {
485
+ "kind": "object",
486
+ "fields": {
487
+ "description": {
488
+ "kind": "string"
489
+ }
490
+ }
491
+ },
492
+ "data": {
493
+ "kind": "object",
494
+ "fields": {
495
+ "description": {
496
+ "kind": "string"
497
+ }
498
+ }
499
+ }
500
+ }
501
+ }
502
+ }
503
+ }
504
+ }
505
+ },
447
506
  {
448
507
  "kind": "object",
449
508
  "fields": {
@@ -541,6 +600,137 @@ export const VIEW_SCHEMA = {
541
600
  }
542
601
  }
543
602
  },
603
+ {
604
+ "kind": "object",
605
+ "fields": {
606
+ "name": {
607
+ "kind": "string"
608
+ },
609
+ "description": {
610
+ "kind": "string"
611
+ },
612
+ "parameters": {
613
+ "kind": "object",
614
+ "fields": {
615
+ "type": {
616
+ "kind": "string"
617
+ },
618
+ "required": {
619
+ "kind": "array",
620
+ "variants": [
621
+ {
622
+ "kind": "string"
623
+ }
624
+ ]
625
+ },
626
+ "properties": {
627
+ "kind": "object",
628
+ "fields": {
629
+ "entryId": {
630
+ "kind": "object",
631
+ "fields": {
632
+ "type": {
633
+ "kind": "string"
634
+ },
635
+ "description": {
636
+ "kind": "string"
637
+ }
638
+ }
639
+ },
640
+ "ops": {
641
+ "kind": "object",
642
+ "fields": {
643
+ "type": {
644
+ "kind": "string"
645
+ },
646
+ "items": {
647
+ "kind": "object",
648
+ "fields": {}
649
+ },
650
+ "description": {
651
+ "kind": "string"
652
+ }
653
+ }
654
+ }
655
+ }
656
+ }
657
+ }
658
+ }
659
+ }
660
+ },
661
+ {
662
+ "kind": "object",
663
+ "fields": {
664
+ "name": {
665
+ "kind": "string"
666
+ },
667
+ "description": {
668
+ "kind": "string"
669
+ },
670
+ "parameters": {
671
+ "kind": "object",
672
+ "fields": {
673
+ "type": {
674
+ "kind": "string"
675
+ },
676
+ "required": {
677
+ "kind": "array",
678
+ "variants": [
679
+ {
680
+ "kind": "string"
681
+ }
682
+ ]
683
+ },
684
+ "properties": {
685
+ "kind": "object",
686
+ "fields": {
687
+ "to": {
688
+ "kind": "object",
689
+ "fields": {
690
+ "type": {
691
+ "kind": "string"
692
+ },
693
+ "description": {
694
+ "kind": "string"
695
+ }
696
+ }
697
+ },
698
+ "text": {
699
+ "kind": "object",
700
+ "fields": {
701
+ "type": {
702
+ "kind": "string"
703
+ },
704
+ "description": {
705
+ "kind": "string"
706
+ }
707
+ }
708
+ },
709
+ "files": {
710
+ "kind": "object",
711
+ "fields": {
712
+ "type": {
713
+ "kind": "string"
714
+ },
715
+ "items": {
716
+ "kind": "object",
717
+ "fields": {
718
+ "type": {
719
+ "kind": "string"
720
+ }
721
+ }
722
+ },
723
+ "description": {
724
+ "kind": "string"
725
+ }
726
+ }
727
+ }
728
+ }
729
+ }
730
+ }
731
+ }
732
+ }
733
+ },
544
734
  {
545
735
  "kind": "object",
546
736
  "fields": {
@@ -791,6 +981,29 @@ export const VIEW_SCHEMA = {
791
981
  }
792
982
  }
793
983
  }
984
+ },
985
+ {
986
+ "kind": "object",
987
+ "fields": {
988
+ "name": {
989
+ "kind": "string"
990
+ },
991
+ "description": {
992
+ "kind": "string"
993
+ },
994
+ "parameters": {
995
+ "kind": "object",
996
+ "fields": {
997
+ "type": {
998
+ "kind": "string"
999
+ },
1000
+ "properties": {
1001
+ "kind": "object",
1002
+ "fields": {}
1003
+ }
1004
+ }
1005
+ }
1006
+ }
794
1007
  }
795
1008
  ]
796
1009
  },
@@ -219,6 +219,9 @@ export function applySessionMetaPatch(draft, patch) {
219
219
  draft.sharing = {
220
220
  scope: "people",
221
221
  users: [...patch.sharing.users],
222
+ ...(patch.sharing.groups === undefined
223
+ ? {}
224
+ : { groups: [...patch.sharing.groups] }),
222
225
  level: patch.sharing.level,
223
226
  };
224
227
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The ordered shutdown (gateway C2's extraction of serve.ts's inline
3
- * `shutdown`): seven steps, each in its own try/catch so a failure never
3
+ * `shutdown`): eight steps, each in its own try/catch so a failure never
4
4
  * skips the remaining steps, with every failure collected into one
5
5
  * `AggregateError("sigrun shutdown failed")` after ALL steps ran (the
6
6
  * pre-extraction semantics, byte-for-byte).
@@ -20,15 +20,16 @@
20
20
  * settles, then deliver `harnessShutdown` — handlers may commit);
21
21
  * 5. `gateway.stop` — the engine stops only once every settle has been
22
22
  * delivered (its adapters' outbound side saw the final answers);
23
- * 6. `stopProviderRefresh` — the interval scheduler + the bounded
23
+ * 6. `stopSandboxWorkers` — all per-user workers and their commands stop;
24
+ * 7. `stopProviderRefresh` — the interval scheduler + the bounded
24
25
  * boot-refresh join (no catalog fetch outlives the shutdown);
25
- * 7. `closeHarness` — the seal (joins tasks, seals storage).
26
+ * 8. `closeHarness` — the seal (joins tasks, seals storage).
26
27
  *
27
28
  * Does NOT exit the process — the caller owns exit policy (the deploy
28
29
  * entry exits 0/1; the CLI's `run` returns to its exit code).
29
30
  */
30
31
  /**
31
- * Build the ordered shutdown over the seven steps. Failures from ANY step
32
+ * Build the ordered shutdown over the eight steps. Failures from ANY step
32
33
  * are collected (the remaining steps still run) and thrown together as
33
34
  * `AggregateError(failures, "sigrun shutdown failed")` at the end; a clean
34
35
  * run resolves.
@@ -66,6 +67,12 @@ export function createShutdown(steps) {
66
67
  catch (error) {
67
68
  failures.push(error);
68
69
  }
70
+ try {
71
+ await steps.stopSandboxWorkers();
72
+ }
73
+ catch (error) {
74
+ failures.push(error);
75
+ }
69
76
  try {
70
77
  await steps.stopProviderRefresh();
71
78
  }
@@ -147,6 +147,17 @@ export async function serveWebuiBundle(app, bundle) {
147
147
  await app.register(fastifyStatic, {
148
148
  root: bundle.dir,
149
149
  prefix: "/",
150
+ // U7 Task 3 (spec §11.2): the webui's HTML responses carry the
151
+ // one-directive CSP — remote https image sources are already
152
+ // rewritten to the /v1/image proxy at serve time, so img-src
153
+ // stays closed to the server itself plus data: URLs. A single
154
+ // img-src directive restricts images and nothing else: scripts
155
+ // and styles are unaffected. Static ASSETS keep no CSP.
156
+ setHeaders(res, path) {
157
+ if (path.endsWith(".html")) {
158
+ res.header("content-security-policy", "img-src 'self' data:");
159
+ }
160
+ },
150
161
  });
151
162
  app.setNotFoundHandler(async (request, reply) => {
152
163
  const path = (request.raw.url ?? "/").split("?")[0] ?? "/";
@@ -156,6 +167,13 @@ export async function serveWebuiBundle(app, bundle) {
156
167
  return await reply
157
168
  .code(200)
158
169
  .type("text/html; charset=utf-8")
170
+ // U7 Task 3 (spec §11.2): the webui's HTML responses carry
171
+ // the one-directive CSP — remote https image sources are
172
+ // already rewritten to the /v1/image proxy at serve time, so
173
+ // img-src stays closed to the server itself plus data: URLs.
174
+ // A single img-src directive restricts images and nothing
175
+ // else: scripts and styles are unaffected.
176
+ .header("content-security-policy", "img-src 'self' data:")
159
177
  .send(bundle.indexHtml);
160
178
  }
161
179
  // Fastify's default 404 body shape, restated so the API error surface
@@ -35,7 +35,7 @@ export class ActionDedup {
35
35
  * duplicate, `undefined` when never seen, `null` when the actionId
36
36
  * belongs to a different instance (treated as fresh by the caller).
37
37
  */
38
- check(actionId, composite) {
38
+ check(actionId, composite, fingerprint) {
39
39
  const entry = this.entries.get(actionId);
40
40
  if (entry === undefined)
41
41
  return undefined;
@@ -45,12 +45,19 @@ export class ActionDedup {
45
45
  }
46
46
  if (entry.composite !== composite)
47
47
  return null;
48
+ // A fingerprint mismatch is NOT a retry (the class docstring's
49
+ // rule): the id was reused by another client, so the caller runs
50
+ // the handler. The new result OVERWRITES the record (the latest
51
+ // invocation owns the id from here).
52
+ if (entry.fingerprint !== fingerprint)
53
+ return undefined;
48
54
  return { result: entry.result };
49
55
  }
50
56
  /** Record the first result of one actionId for one instance. */
51
- record(actionId, composite, result) {
57
+ record(actionId, composite, fingerprint, result) {
52
58
  this.entries.set(actionId, {
53
59
  composite,
60
+ fingerprint,
54
61
  result,
55
62
  expiresAt: this.clock() + DEDUP_WINDOW_MS,
56
63
  });
@@ -24,12 +24,14 @@
24
24
  * maps it to `500 surface_failed` (bodies carry `errors` only when
25
25
  * `server.dev`). The extension stays loaded.
26
26
  */
27
+ import { rewriteImages } from "../images/rewrite.js";
27
28
  import { validateTree } from "../../blocks/validate.js";
28
29
  import { AccessCache } from "./access-cache.js";
29
30
  import { ActionDedup } from "./dedup.js";
30
31
  import { eventKey, instanceKey as defaultInstanceKey } from "./instance.js";
31
32
  import { sniffMime } from "./mime.js";
32
33
  import { ActionRateLimit } from "./rate-limit.js";
34
+ import { NamedUserRequiredError } from "./settings-surfaces.js";
33
35
  import { WatcherRegistry } from "./watchers.js";
34
36
  /** The shared-data op-log window (spec §7.3): the last 100 updates. */
35
37
  export const SHARED_OPS_WINDOW = 100;
@@ -40,6 +42,21 @@ const TIMED_OUT_RESULT = {
40
42
  ok: false,
41
43
  error: { message: "timed out" },
42
44
  };
45
+ /**
46
+ * The reserved chat "slug" (spec §8.2): a NUL byte can never appear in
47
+ * an extension slug, so chat composites never collide with one.
48
+ */
49
+ export const CHAT_SURFACE_SLUG = "\u0000chat";
50
+ /** Decompose either address form into the service's table keys. */
51
+ function partsOf(address) {
52
+ return "conversationId" in address
53
+ ? {
54
+ slug: CHAT_SURFACE_SLUG,
55
+ id: String(address.conversationId),
56
+ key: address.entryId,
57
+ }
58
+ : { slug: address.slug, id: address.id, key: address.key };
59
+ }
43
60
  /** Errors `act` reports without calling the handler. */
44
61
  export class ActionRateLimitedError extends Error {
45
62
  constructor() {
@@ -119,12 +136,25 @@ export class SurfaceService {
119
136
  maxUploadBytes;
120
137
  watchers;
121
138
  publishChanged;
139
+ /** The image-signer (U7 Task 1, spec §11.2): every tree/data response passes through the rewriter before leaving the server. */
140
+ imageSign;
141
+ extra = [];
122
142
  constructor(bindings, options) {
123
143
  this.bindings = bindings;
144
+ this.extra.push(...(options?.settingsSurfaces ?? []));
124
145
  this.cache = new AccessCache();
125
146
  this.maxUploadBytes = options?.maxUploadBytes ?? Number.MAX_SAFE_INTEGER;
126
147
  this.watchers = options?.watchers ?? new WatcherRegistry();
127
148
  this.publishChanged = options?.publishChanged;
149
+ this.imageSign = options?.imageSign;
150
+ }
151
+ /**
152
+ * Attach the settings surfaces post-construction (their save
153
+ * handlers close over THIS service for the optimistic-concurrency
154
+ * rev read, so they can only be built once the service exists).
155
+ */
156
+ attachSettingsSurfaces(extra) {
157
+ this.extra.push(...extra);
128
158
  }
129
159
  /** The watcher registry (the events route reads it per event). */
130
160
  get watcherRegistry() {
@@ -139,12 +169,19 @@ export class SurfaceService {
139
169
  if (def !== undefined)
140
170
  return { slug, def };
141
171
  }
172
+ for (const binding of this.extra) {
173
+ if (binding.extension !== slug)
174
+ continue;
175
+ const def = binding.defs.find((entry) => entry.id === id);
176
+ if (def !== undefined)
177
+ return { slug, def };
178
+ }
142
179
  return undefined;
143
180
  }
144
181
  /** The instance key: the surface's own `instanceKey` or the default. */
145
- keyFor(def, params) {
182
+ keyFor(def, params, principal) {
146
183
  if (def.instanceKey !== undefined)
147
- return def.instanceKey(params);
184
+ return def.instanceKey(params, principal);
148
185
  return defaultInstanceKey(params);
149
186
  }
150
187
  /** The per-instance state, created on first touch (revs start at 0). */
@@ -166,7 +203,7 @@ export class SurfaceService {
166
203
  const located = this.locate(slug, id);
167
204
  if (located === undefined)
168
205
  return undefined;
169
- const key = this.keyFor(located.def, params);
206
+ const key = this.keyFor(located.def, params, principal);
170
207
  const result = await this.callRender(located, key, params, principal);
171
208
  const validation = validateTree(result.tree, {
172
209
  placement: located.def.placement,
@@ -197,8 +234,11 @@ export class SurfaceService {
197
234
  }
198
235
  }
199
236
  return {
200
- tree: validation.tree,
201
- data: result.data,
237
+ // SAFETY: the rewriter is a structural JSON walk returning the same
238
+ // shape; `Tree` (interfaces) has no implicit index signature, so the
239
+ // generic JsonValue round-trip needs the bridge cast.
240
+ tree: this.rewriteForWire(validation.tree),
241
+ data: this.rewriteForWire(result.data),
202
242
  rev: state.rev,
203
243
  treeRev: state.treeRev,
204
244
  access: result.access,
@@ -215,7 +255,7 @@ export class SurfaceService {
215
255
  const located = this.locate(slug, id);
216
256
  if (located === undefined)
217
257
  return undefined;
218
- const key = this.keyFor(located.def, params);
258
+ const key = this.keyFor(located.def, params, principal);
219
259
  const state = this.stateFor(slug, id, key);
220
260
  const access = await this.accessFor(slug, id, key, params, principal);
221
261
  if (access === "none")
@@ -231,7 +271,18 @@ export class SurfaceService {
231
271
  const ops = state.ops
232
272
  .filter((entry) => entry.rev > since)
233
273
  .flatMap((entry) => entry.ops);
234
- return { rev: state.rev, treeRev: state.treeRev, ops };
274
+ // Ops answers are data responses too (spec §7.3): the client folds
275
+ // them into its local copy, so they must carry the SAME rewritten
276
+ // sources the full data answer would — else fold(data, ops) drifts
277
+ // from the server's data.
278
+ return {
279
+ rev: state.rev,
280
+ treeRev: state.treeRev,
281
+ // SAFETY: ops are wire JSON (DataOp values); the rewriter returns
282
+ // the same shape, and `unknown[]`'s lack of an index signature is
283
+ // the only reason for the bridge cast.
284
+ ops: this.rewriteForWire(ops),
285
+ };
235
286
  }
236
287
  let data;
237
288
  let access2;
@@ -274,7 +325,20 @@ export class SurfaceService {
274
325
  if (located.def.sharedData !== true) {
275
326
  state.rev += 1;
276
327
  }
277
- return { rev: state.rev, treeRev: state.treeRev, data };
328
+ return { rev: state.rev, treeRev: state.treeRev, data: this.rewriteForWire(data) };
329
+ }
330
+ /**
331
+ * The wire-side image rewrite (U7 Task 1, spec §11.1/§11.2): every
332
+ * tree and data (and ops) response passes through `rewriteImages`
333
+ * before leaving the server, replacing https image sources with
334
+ * signed proxy URLs. Absent signer → verbatim pass-through. This is
335
+ * the hook point U5's chat surfaces reuse when they land: they call
336
+ * the same `rewriteImages` over their trees/data.
337
+ */
338
+ rewriteForWire(value) {
339
+ return this.imageSign === undefined
340
+ ? value
341
+ : rewriteImages(value, this.imageSign);
278
342
  }
279
343
  /**
280
344
  * One principal's access to one instance (spec §6.5): the cached
@@ -292,8 +356,8 @@ export class SurfaceService {
292
356
  return result.access;
293
357
  }
294
358
  /** The composite instance key (`slugidkey`) for one def + params. */
295
- compositeFor(slug, def, params) {
296
- return `${slug}${def.id}${this.keyFor(def, params)}`;
359
+ compositeFor(slug, def, params, principal) {
360
+ return `${slug}${def.id}${this.keyFor(def, params, principal)}`;
297
361
  }
298
362
  /**
299
363
  * The effective upload cap for one field (spec §7.1): the field's
@@ -344,18 +408,48 @@ export class SurfaceService {
344
408
  throw new ActionRateLimitedError();
345
409
  }
346
410
  const composite = `${slug}${id}${key}`;
411
+ // The request fingerprint (action + params + payload)
412
+ // disambiguates a RETRY (same body — replay the stored result)
413
+ // from an actionId REUSE (a second client whose channel restarted
414
+ // its id sequence — two tabs of one webui share the module-level
415
+ // counter; U4 Task 4).
416
+ const fingerprint = JSON.stringify({ action, params, payload });
347
417
  if (actionId !== undefined) {
348
- const hit = this.dedup.check(actionId, composite);
418
+ const hit = this.dedup.check(actionId, composite, fingerprint);
349
419
  if (hit !== undefined && hit !== null)
350
420
  return hit.result;
351
421
  }
352
- const result = await this.invokeWithTimeout(() => handler({ principal, params, key, signal }, payload));
422
+ let result;
423
+ try {
424
+ result = await this.invokeWithTimeout(() => handler({ principal, params, key, signal }, payload));
425
+ }
426
+ catch (cause) {
427
+ if (cause instanceof NamedUserRequiredError)
428
+ throw cause;
429
+ throw new SurfaceFailedError(`surface ${slug}/${id} action "${action}" threw: ${cause instanceof Error ? cause.message : String(cause)}`);
430
+ }
353
431
  const wrapped = this.wrapResult(slug, id, key, result);
354
432
  if (actionId !== undefined) {
355
- this.dedup.record(actionId, composite, wrapped);
433
+ this.dedup.record(actionId, composite, fingerprint, wrapped);
356
434
  }
357
435
  return wrapped;
358
436
  }
437
+ /** The instance's current rev (the save action's optimistic check). */
438
+ currentRev(slug, id, key) {
439
+ return this.stateFor(slug, id, key).rev;
440
+ }
441
+ /**
442
+ * The instance key for one surface + params, or `undefined` when the
443
+ * surface is unknown (the routes' located-check — covers extension
444
+ * views/panels AND settings surfaces, one source). A throwing
445
+ * `instanceKey` propagates (the extension-fault 500 path).
446
+ */
447
+ instanceKeyFor(slug, id, params, principal) {
448
+ const located = this.locate(slug, id);
449
+ if (located === undefined)
450
+ return undefined;
451
+ return this.keyFor(located.def, params, principal);
452
+ }
359
453
  /**
360
454
  * Deliver one upload (spec §7.1/§7.2): the bytes were capped at the
361
455
  * route (field `maxBytes` clamped by `server.maxUploadBytes`); the
@@ -476,6 +570,78 @@ export class SurfaceService {
476
570
  });
477
571
  return state.rev;
478
572
  }
573
+ /**
574
+ * Record one chat-block change (spec §8.2: chat data is shared by
575
+ * everyone who can see the conversation, so ops are kept like
576
+ * `sharedData` surfaces). Only the chat surface ROUTES call this —
577
+ * the core handler itself publishes the Task-1 `surface.changed`
578
+ * seam event and never touches this table, and an `update_blocks`
579
+ * agent edit likewise publishes ONLY the seam event (never an
580
+ * announceChatRev), so after an agent edit the in-memory rev no
581
+ * longer continues the recorded sequence and the rev-match guard
582
+ * below forces the client's next fetch to a full-data answer.
583
+ * `rev` is the DURABLE family's revision — the chat block's source
584
+ * of truth — so the table merely mirrors it: a rev that does not
585
+ * continue the recorded sequence (or a restart resetting memory)
586
+ * breaks the replay window's contiguity, and the window RESETS
587
+ * rather than ever answering a fetch across a missing rev.
588
+ */
589
+ announceChatRev(address, rev, ops) {
590
+ const { slug, id, key } = partsOf(address);
591
+ const state = this.stateFor(slug, id, key);
592
+ if (ops !== undefined && rev === state.rev + 1) {
593
+ state.rev = rev;
594
+ state.ops.push({ rev, ops: [...ops.ops] });
595
+ while (state.ops.length > SHARED_OPS_WINDOW)
596
+ state.ops.shift();
597
+ return;
598
+ }
599
+ // An ops-less announcement, or a rev that skips: the window is no
600
+ // longer contiguous from any `since` — drop it; the next contiguous
601
+ // recorded action rebuilds it.
602
+ if (rev > state.rev)
603
+ state.rev = rev;
604
+ state.ops = [];
605
+ }
606
+ /**
607
+ * The ops since one rev for a chat block (the `data?since=` answer):
608
+ * the kept ops when the window still covers `since` AND the in-memory
609
+ * rev matches the durable one the caller read (a stale table serves
610
+ * nothing — the caller answers the full data). `undefined` otherwise.
611
+ */
612
+ chatOpsSince(address, since, currentRev) {
613
+ const { slug, id, key } = partsOf(address);
614
+ const state = this.instances.get(`${slug}\u0001${id}\u0001${key}`);
615
+ if (state === undefined)
616
+ return undefined;
617
+ if (state.rev !== currentRev)
618
+ return undefined;
619
+ if (since < state.rev - state.ops.length || since > state.rev) {
620
+ return undefined;
621
+ }
622
+ return {
623
+ rev: state.rev,
624
+ ops: state.ops
625
+ .filter((entry) => entry.rev > since)
626
+ .flatMap((entry) => entry.ops),
627
+ };
628
+ }
629
+ /** One chat action's rate-limit token (the ext route's own gate). */
630
+ chatTakeToken(address, user) {
631
+ const { slug, id } = partsOf(address);
632
+ return this.rateLimit.take(slug, id, user);
633
+ }
634
+ /** The dedup lookup for one chat action (same table as ext actions). */
635
+ chatDedupCheck(actionId, address, fingerprint) {
636
+ const { slug, id, key } = partsOf(address);
637
+ const hit = this.dedup.check(actionId, `${slug}\u0001${id}\u0001${key}`, fingerprint);
638
+ return hit === undefined || hit === null ? hit : hit.result;
639
+ }
640
+ /** Record one chat action's first result (the dedup entry). */
641
+ chatDedupRecord(actionId, address, fingerprint, result) {
642
+ const { slug, id, key } = partsOf(address);
643
+ this.dedup.record(actionId, `${slug}\u0001${id}\u0001${key}`, fingerprint, result);
644
+ }
479
645
  /** Call the surface's `render`, mapping throws to SurfaceFailedError. */
480
646
  async callRender(located, key, params, principal) {
481
647
  try {
@@ -487,6 +653,8 @@ export class SurfaceService {
487
653
  });
488
654
  }
489
655
  catch (cause) {
656
+ if (cause instanceof NamedUserRequiredError)
657
+ throw cause;
490
658
  throw new SurfaceFailedError(`surface ${located.slug}/${located.def.id} render() threw: ${cause instanceof Error ? cause.message : String(cause)}`);
491
659
  }
492
660
  }