@zackbart/connecta 0.26.0 → 0.26.1

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 CHANGED
@@ -4,6 +4,122 @@ All notable changes to this package are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.26.1 — 2026-09-26
8
+
9
+ This patch fixes slow OAuth restarts, unbounded downstream authorization waits,
10
+ and operator pages that signed people out after a temporary load failure.
11
+ Connect now opens consent in one click, and browser-facing pages share the
12
+ configured theme. Agent guidance preserves one-time write results and keeps
13
+ an authorized batch in one resumable program. Approval defaults and the eight
14
+ MCP tools are unchanged. No configuration or storage migration is required;
15
+ deployments without the operator UI can ignore the page changes. OAuth starts
16
+ abort downstream work after 30 seconds, but uncancellable storage resets and
17
+ cleanup must finish before the response. Restart reuses a matching client
18
+ registration; a revoked client may still fail at consent or callback and need
19
+ another restart. The Node template now pins 0.26.1.
20
+
21
+ ### Added
22
+
23
+ - **Continue or restart an operator OAuth start.** `POST /ui/oauth/<id>`
24
+ takes `?mode=continue` or `?mode=restart`. `restart`, still the default
25
+ when `mode` is absent, creates a new epoch and clears the grant and
26
+ discovery while retaining a matching issuer-bound client registration. `continue` hands back the pending
27
+ authorization URL when it was written in the last ten minutes. Otherwise it
28
+ starts a flow in the current epoch, reusing a stored registration when one
29
+ exists. A first connection still registers dynamically. A disconnected
30
+ connector resets first. A response with a URL now carries `reused`. A continue that reused a
31
+ URL or found the connection healthy leaves the cached catalog alone. Any
32
+ other `mode` is a 400. After a `publicUrl` change, continue keeps a client
33
+ registered for the old callback, which the authorization server refuses;
34
+ restart recovers. `ConnectorStatus` gains the optional
35
+ `authorizationReused`, set by `remoteMcp()`'s `startAuth`.
36
+
37
+ ### Changed
38
+
39
+ - **Pending authorization URLs expire for reuse.** A non-forced `startAuth`,
40
+ including `authorize_connector` without `force`, reissues a pending URL only
41
+ when it is under ten minutes old. The pending URL's envelope now records its
42
+ write time, which older readers ignore. A URL written before this release,
43
+ or under a pre-epoch generation, starts a fresh flow instead.
44
+ - **OAuth cleanup deletes run concurrently.** A restart's epoch cleanup,
45
+ `clearPending`, and `invalidateCredentials("all")` keep up to six deletes
46
+ in flight, the Workers limit on simultaneous connections. Every delete is
47
+ still attempted, and a falsy rejection now counts as a failure. A retired
48
+ epoch's manifest is still removed only after all of its values. Catalog
49
+ invalidation deletes the root and its chunks together under the registry's
50
+ chunk I/O bound, so a failed root delete no longer skips the chunks.
51
+
52
+ - Every page connecta renders for a person now shares one token layer and one
53
+ layout: the operator shell, the OAuth callback, a browser's 404, and the
54
+ artifact frame follow `branding.theme` and light or dark. The OAuth callback
55
+ names the outcome in its heading with a status mark, names the connector by
56
+ title only after the state check, uses `branding.productName` in its copy,
57
+ and folds the agent fix prompt under "Details for the operator". Refusals
58
+ stay byte-identical across connectors and paths.
59
+ - A request whose `Accept` names `text/html` gets a themed 404 page; every
60
+ other client keeps the plain `Not Found` body.
61
+ - Markdown artifacts take the deployment's resolved scheme and tokens instead
62
+ of the OS palette, and drop an opening `# Heading` that only repeats the
63
+ title the viewer already shows. The frame waits on the viewer's surface with
64
+ a loading line, and says so if the page never arrives.
65
+ - Without the operator UI, callback and 404 pages no longer link the default
66
+ `/favicon.svg`, which only the UI serves.
67
+ - The operator UI starts OAuth in one click. Connect opens the provider's
68
+ page in a new tab straight away and asks the server to continue a pending
69
+ authorization rather than restart it; only Reconnect on a healthy
70
+ connection restarts, behind an in-page confirm. A tab returning to the page
71
+ quietly re-reads the status of connectors waiting on authorization.
72
+ - Operator UI notices appear in the card that caused them, in fixed
73
+ sentences rather than the route's error text; problems fixed by authorizing
74
+ or adding a credential are warnings rather than errors, with one primary
75
+ action per row. Activity and artifact states, the confirm dialogs, the
76
+ mobile masthead, and the vocabulary shown to people (outcomes, actors,
77
+ timestamps) now read the same across pages and use `branding.productName`.
78
+
79
+ ### Fixed
80
+
81
+ - Operator OAuth starts now abort downstream discovery and registration after
82
+ 30 seconds or browser cancellation, with a fixed timeout response. Every
83
+ reset already started by the request, including issuer-mismatch recovery,
84
+ drains before catalog invalidation and response so a delayed generation
85
+ write cannot replace a later flow. These uncancellable storage waits can
86
+ exceed the deadline. Disconnect still finishes after browser cancellation.
87
+ - Forced OAuth restarts reuse registrations bound to the issuer, redirect URI,
88
+ client metadata, connector settings, and owner partition. Credentials are
89
+ re-sealed for the replacement epoch, and fresh discovery detects issuer
90
+ changes. Disconnect and issuer-mismatch recovery discard the registration.
91
+ The SDK cannot detect a revoked client while constructing a consent URL;
92
+ a callback refusal clears it so the next restart can register again.
93
+ - Agent sampling advice now applies to reads. For a one-time write, agents
94
+ reduce the full result in the program or page a direct-call result through
95
+ `get_result`, without repeating the write to recover discarded output.
96
+ - Authorized batches stay in one resumable program. Guidance explains how
97
+ tool-scoped approval covers repeated calls to one address while other
98
+ approval-required writes retain their own approvals. The call-scoped
99
+ default and host enforcement are unchanged.
100
+ - **A restart's cleanup no longer grows with every earlier restart.** Each
101
+ OAuth restart re-deleted every epoch the connector had ever retired, one key
102
+ at a time, and restart 1,001 failed forever with a full cleanup backlog. A
103
+ restart now deletes the epoch it retires, retries any of the eight most
104
+ recent epochs whose cleanup failed (their manifest outlives their values),
105
+ and sweeps at most 16 epochs retired more than 24 hours ago, dropping each
106
+ from the lineage only when all of its keys are gone. A Disconnect that
107
+ reported a failed cleanup still deletes the old grant when retried. The
108
+ cleanup work stays bounded independently of earlier resets. Residue a late
109
+ writer leaves in a younger epoch stays unreadable behind the fence until the
110
+ sweep reaches it. A late writer whose cleanup fails in an epoch already
111
+ listed now restarts that epoch's grace. The accepted assumption is that no
112
+ request holds a retired epoch for a day. The lineage records retirement
113
+ times in a sibling record older releases ignore, and its cap rises to 5,000
114
+ epochs. A connector stuck at the old 1,000 wall restarts the day it
115
+ upgrades. Rolling back is clean unless a lineage has grown past 1,000. See
116
+ [auth](./documentation/auth.md#starting-restarting-and-retiring-an-oauth-epoch).
117
+ - A failed or unreachable `/ui/data` no longer signs the operator out. Only a
118
+ 401 or 403 on that read returns to the token gate; anything else keeps the
119
+ page and offers Retry. A connector whose details fail to load says whether
120
+ the session, the browser's connection, or the downstream service is at
121
+ fault, and only the last offers the fix prompt.
122
+
7
123
  ## 0.26.0 — 2026-09-25
8
124
 
9
125
  Programs can now write with host approval. When a program reaches a tool that
@@ -42,19 +42,23 @@ const DEFINE_GLOBAL = '(()=>{const f=(o)=>{if(o!==null&&typeof o==="object"){for
42
42
  /**
43
43
  * The complete document a viewer or render check loads. `data` maps document
44
44
  * names to their stored, script-safe JSON text, which is spliced in as-is.
45
+ *
46
+ * `theme` reaches a Markdown page only, whose styling is connecta's own. An
47
+ * HTML page is the author's, down to its colors, and is never restyled; the
48
+ * frame around it is what follows the deployment's scheme.
45
49
  */
46
50
  export function buildFrameDocument(input) {
47
51
  let page = input.source;
48
52
  if (input.kind === "markdown") {
49
53
  try {
50
- page = markdownPage(input.source, input.global.title);
54
+ page = markdownPage(input.source, input.global.title, input.theme);
51
55
  }
52
56
  catch (error) {
53
57
  if (!(error instanceof MarkdownNestingError))
54
58
  throw error;
55
59
  // Old or externally populated stores may hold a page that today's
56
60
  // validator would reject. Render fixed text instead of throwing here.
57
- page = markdownPage("This page has too many nested block quotes or lists to display.", input.global.title);
61
+ page = markdownPage("This page has too many nested block quotes or lists to display.", input.global.title, input.theme);
58
62
  }
59
63
  }
60
64
  const meta = scriptSafeJson(input.global);
@@ -13,6 +13,8 @@
13
13
  // of text, a delimiter that found no closer is never searched for again, and
14
14
  // nesting stops at a fixed depth. A megabyte of `[[[[` or `**` renders in
15
15
  // linear time, because this runs on every page view.
16
+ import { resolveTheme, schemeAttribute, themeCss, } from "../branding.js";
17
+ import { TOKENS_CSS } from "../page-styles.js";
16
18
  const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : "&#39;");
17
19
  const PUNCTUATION = /^[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]$/;
18
20
  const MAX_INLINE_DEPTH = 8;
@@ -430,20 +432,47 @@ export function renderMarkdown(source) {
430
432
  .map((line) => line.replace(/^\t+/, (tabs) => " ".repeat(tabs.length)));
431
433
  return blocks(lines);
432
434
  }
433
- const MARKDOWN_STYLE = ":root{color-scheme:light dark}body{margin:0;font:16px/1.6 system-ui,-apple-system,'Segoe UI',sans-serif;" +
434
- "color:#1f2328;background:#fff}main{max-width:820px;margin:0 auto;padding:40px 24px}" +
435
+ /**
436
+ * Every color, font, and radius comes from the shared tokens, so a Markdown
437
+ * page reads as part of the viewer around it: the deployment's accent and
438
+ * fonts, and the palette of the scheme the page was given rather than whatever
439
+ * the OS prefers. The background is the frame's `--surface`, so the page sits
440
+ * flush in its card instead of painting a second one.
441
+ */
442
+ const MARKDOWN_STYLE = "body{margin:0;font:16px/1.6 var(--sans);color:var(--text);background:var(--surface)}" +
443
+ "main{max-width:820px;margin:0 auto;padding:32px 24px}main>:first-child{margin-top:0}" +
435
444
  "h1,h2,h3,h4{line-height:1.25;margin:1.6em 0 .6em}h1{font-size:2em}h2{font-size:1.5em}" +
436
- "a{color:#0969da}code{font:0.9em ui-monospace,SFMono-Regular,Menlo,monospace;background:#f3f4f6;padding:.1em .3em;border-radius:4px}" +
437
- "pre{overflow:auto;padding:16px;background:#f3f4f6;border-radius:8px}pre code{background:none;padding:0}" +
438
- "blockquote{margin:0;padding:0 1em;color:#59636e;border-left:4px solid #d1d9e0}" +
439
- "table{border-collapse:collapse;display:block;overflow:auto}th,td{padding:6px 12px;border:1px solid #d1d9e0}" +
440
- "img{max-width:100%}hr{border:0;border-top:1px solid #d1d9e0}" +
441
- "@media (prefers-color-scheme:dark){body{color:#e6edf3;background:#0d1117}a{color:#4493f8}" +
442
- "code,pre{background:#161b22}blockquote{color:#9198a1;border-color:#3d444d}th,td,hr{border-color:#3d444d}}";
443
- /** A complete page for a Markdown artifact: the rendered body in `<main id="artifact-root">`. */
444
- export function markdownPage(source, title) {
445
- return (`<!doctype html><html lang="en"><head><meta charset="utf-8">` +
445
+ "a{color:var(--link)}code{font:.9em var(--mono);background:var(--surface-2);padding:.1em .3em;border-radius:4px}" +
446
+ "pre{overflow:auto;padding:16px;background:var(--surface-2);border-radius:calc(var(--radius) - 2px)}pre code{background:none;padding:0}" +
447
+ "blockquote{margin:0;padding:0 1em;color:var(--muted);border-left:4px solid var(--border-strong)}" +
448
+ "table{border-collapse:collapse;display:block;overflow:auto}th,td{padding:6px 12px;border:1px solid var(--border-strong)}" +
449
+ "th{background:var(--surface-2)}img{max-width:100%}hr{border:0;border-top:1px solid var(--border)}";
450
+ const collapse = (text) => text.replace(/\s+/g, " ").trim().toLowerCase();
451
+ /**
452
+ * The source without a leading `# Heading` that only repeats the title. The
453
+ * viewer shows the title as the page's heading directly above the frame, so a
454
+ * page opening with the same words would say them twice. Any other opening
455
+ * heading is the author's and stays.
456
+ */
457
+ function withoutRepeatedTitle(source, title) {
458
+ const lines = source.replace(/\r\n?/g, "\n").split("\n");
459
+ const first = lines.findIndex((line) => line.trim() !== "");
460
+ const heading = first < 0 ? undefined : HEADING.exec(lines[first] ?? "");
461
+ if (heading?.[1] !== "#" || !collapse(title) || collapse(heading[2] ?? "") !== collapse(title)) {
462
+ return source;
463
+ }
464
+ return lines.slice(first + 1).join("\n");
465
+ }
466
+ /**
467
+ * A complete page for a Markdown artifact: the rendered body in
468
+ * `<main id="artifact-root">`. `theme` is the deployment's resolved theme; the
469
+ * viewer passes it so a pinned scheme and the operator's tokens reach the
470
+ * frame. Without one the page follows the OS scheme with default tokens.
471
+ */
472
+ export function markdownPage(source, title, theme) {
473
+ const resolved = theme ?? resolveTheme();
474
+ return (`<!doctype html><html lang="en"${schemeAttribute(resolved)}><head><meta charset="utf-8">` +
446
475
  `<meta name="viewport" content="width=device-width,initial-scale=1">` +
447
- `<title>${escapeHtml(title)}</title><style>${MARKDOWN_STYLE}</style></head>` +
448
- `<body><main id="artifact-root">\n${renderMarkdown(source)}\n</main></body></html>`);
476
+ `<title>${escapeHtml(title)}</title><style>${TOKENS_CSS}${themeCss(resolved)}${MARKDOWN_STYLE}</style></head>` +
477
+ `<body><main id="artifact-root">\n${renderMarkdown(withoutRepeatedTitle(source, title))}\n</main></body></html>`);
449
478
  }
@@ -11,6 +11,8 @@
11
11
  // could call — and the fetch directives leave it no network.
12
12
  import { resolveActorLabels, actorKey } from "../routes/actor-labels.js";
13
13
  import { authorize, mayViewArtifacts, privateJson, validateAuthPermissions, } from "../routes/shared.js";
14
+ import { resolveBranding, schemeAttribute, themeCss } from "../branding.js";
15
+ import { TOKENS_CSS } from "../page-styles.js";
14
16
  import { buildFrameDocument, frameCsp } from "./document.js";
15
17
  import { scriptSafeJson } from "./json.js";
16
18
  import { freshnessOf } from "./refresh.js";
@@ -20,19 +22,38 @@ const MAX_QUERY_CHARS = 200;
20
22
  const MAX_PINS = 32;
21
23
  const PIN = /^([A-Za-z_][A-Za-z0-9_]{0,63}):(\d{1,9})$/;
22
24
  const notFound = () => privateJson({ error: "not found" }, { status: 404 });
23
- /** The frame's bootstrap: wait for exactly one document from the parent, then become it. */
24
- function frameBootstrap(expectedOrigin) {
25
+ /** How long the frame waits for its document before saying it did not come. */
26
+ const FRAME_STALL_MS = 15_000;
27
+ /**
28
+ * The frame's bootstrap: wait for exactly one document from the parent, then
29
+ * become it.
30
+ *
31
+ * Until the document arrives the frame shows one quiet line on the viewer's
32
+ * own surface, in the deployment's scheme and tokens, instead of a blank white
33
+ * box; if the handshake never completes, the line says so. None of it is page
34
+ * data — the scheme and tokens are deployment configuration — and all of it is
35
+ * gone the moment `document.open` replaces this document with the page.
36
+ */
37
+ function frameBootstrap(expectedOrigin, theme) {
25
38
  return `<!doctype html>
26
- <html lang="en"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"></head><body>
39
+ <html lang="en"${schemeAttribute(theme)}><head><meta charset="utf-8"><meta name="referrer" content="no-referrer">
40
+ <style>${TOKENS_CSS}${themeCss(theme)}html{background:var(--surface)}body{color:var(--muted);font:.875rem/1.5 var(--sans);margin:0;padding:1.25rem 1.5rem}</style>
41
+ </head><body>
42
+ <p id="frame-status" role="status">Loading page…</p>
27
43
  <script>
28
44
  (() => {
29
45
  const expected = ${scriptSafeJson(expectedOrigin)};
30
46
  if (window.parent === window) return;
47
+ const status = document.getElementById("frame-status");
48
+ const stalled = setTimeout(() => {
49
+ status.textContent = "This page did not load. Reload to try again.";
50
+ }, ${FRAME_STALL_MS});
31
51
  const receive = (event) => {
32
52
  if (event.source !== window.parent || event.origin !== expected) return;
33
53
  const message = event.data;
34
54
  if (!message || message.type !== "document" || typeof message.html !== "string") return;
35
55
  window.removeEventListener("message", receive);
56
+ clearTimeout(stalled);
36
57
  document.open();
37
58
  document.write(message.html);
38
59
  document.close();
@@ -152,6 +173,7 @@ export function artifactRoutes(options) {
152
173
  document: buildFrameDocument({
153
174
  kind: page.head.kind,
154
175
  source: page.source,
176
+ theme: resolveBranding(context.opts.branding).theme,
155
177
  global: {
156
178
  id,
157
179
  title: page.head.title,
@@ -169,7 +191,8 @@ export function artifactRoutes(options) {
169
191
  if (path === "/artifacts/_frame") {
170
192
  if (!read)
171
193
  return privateJson({ error: "method not allowed" }, { status: 405 });
172
- return new Response(request.method === "HEAD" ? null : frameBootstrap(new URL(context.baseUrl).origin), {
194
+ const bootstrap = frameBootstrap(new URL(context.baseUrl).origin, resolveBranding(context.opts.branding).theme);
195
+ return new Response(request.method === "HEAD" ? null : bootstrap, {
173
196
  headers: {
174
197
  "Content-Type": "text/html; charset=utf-8",
175
198
  "Content-Security-Policy": csp,
@@ -215,6 +215,7 @@ export function checkView(input, context) {
215
215
  if (kind === "markdown") {
216
216
  let rendered;
217
217
  try {
218
+ // The served page also carries themeCss (≤~0.5 KB), which FRAME_OVERHEAD_BYTES absorbs.
218
219
  rendered = utf8Bytes(markdownPage(source, input.title ?? "")) + input.dataBytes + FRAME_OVERHEAD_BYTES;
219
220
  }
220
221
  catch (error) {