@zackbart/connecta 0.26.0 → 0.26.2

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 (47) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/README.md +11 -0
  3. package/dist/access-tokens.d.ts +38 -0
  4. package/dist/access-tokens.js +294 -0
  5. package/dist/artifacts/document.js +6 -2
  6. package/dist/artifacts/markdown.js +43 -14
  7. package/dist/artifacts/routes.js +27 -4
  8. package/dist/artifacts/validate.js +1 -0
  9. package/dist/auth/downstream-oauth.js +397 -48
  10. package/dist/auth/oauth-start-reset.js +20 -0
  11. package/dist/branding.d.ts +87 -0
  12. package/dist/branding.js +170 -1
  13. package/dist/connectors/remote-mcp.js +32 -7
  14. package/dist/execute.js +7 -8
  15. package/dist/identity.js +2 -0
  16. package/dist/index.d.ts +6 -2
  17. package/dist/index.js +13 -2
  18. package/dist/module-contracts.d.ts +5 -0
  19. package/dist/oauth-callback-outcome.js +43 -18
  20. package/dist/operator-ui/generated.js +2 -2
  21. package/dist/operator-ui/model.d.ts +1 -0
  22. package/dist/operator-ui/view.js +304 -33
  23. package/dist/page-styles.js +7 -0
  24. package/dist/registry.js +16 -13
  25. package/dist/routes/access-tokens.js +110 -0
  26. package/dist/routes/mcp.js +5 -1
  27. package/dist/routes/oauth-management.js +66 -12
  28. package/dist/routes/oauth.js +54 -87
  29. package/dist/routes/shared.d.ts +3 -1
  30. package/dist/routes/shared.js +6 -1
  31. package/dist/routes/ui.js +9 -1
  32. package/dist/server.js +8 -5
  33. package/dist/skills.js +6 -6
  34. package/dist/types.d.ts +5 -0
  35. package/dist/ui.d.ts +6 -1
  36. package/dist/ui.js +27 -64
  37. package/dist/version.d.ts +1 -1
  38. package/dist/version.js +1 -1
  39. package/documentation/architecture.md +19 -9
  40. package/documentation/auth.md +166 -2
  41. package/documentation/code-mode.md +15 -15
  42. package/documentation/meta-tools.md +12 -5
  43. package/ethos.md +1 -1
  44. package/package.json +5 -1
  45. package/templates/node/README.md +11 -0
  46. package/templates/node/package.json +1 -1
  47. package/templates/node/src/index.ts +5 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,147 @@ All notable changes to this package are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.26.2 — 2026-09-27
8
+
9
+ This patch restores named client tokens as an optional auth module. Deployments
10
+ upgrading from v0.23 can keep their existing stored records and client secrets,
11
+ without rotation, by configuring `accessTokens(storage)` with the same storage
12
+ namespace and preserving their identity grants. The old boolean configuration
13
+ must be replaced. Deployments that omit the module are unchanged. The eight MCP
14
+ tools remain unchanged, and the Node template now pins 0.26.2.
15
+
16
+ ### Added
17
+
18
+ - `@zackbart/connecta/auth/access-tokens` verifies v0.23 `cta_…` tokens and
19
+ restores create, show-once, list, rename, and revoke in the operator UI.
20
+ `identity.accessTokenManagement` explicitly permits interactive operators;
21
+ client tokens cannot administer tokens or connection credentials. New issuance
22
+ requires atomic storage and reserves capacity across concurrent instances.
23
+
24
+ ### Fixed
25
+
26
+ - Existing token ids, activity labels, and principal bindings survive the upgrade.
27
+ Malformed or corrupt records fail closed, including principal fields that
28
+ JavaScript regular expressions previously coerced into strings. Revocation
29
+ deletes the admission lookup before updating metadata.
30
+
31
+
32
+ ## 0.26.1 — 2026-09-26
33
+
34
+ This patch fixes slow OAuth restarts, unbounded downstream authorization waits,
35
+ and operator pages that signed people out after a temporary load failure.
36
+ Connect now opens consent in one click, and browser-facing pages share the
37
+ configured theme. Agent guidance preserves one-time write results and keeps
38
+ an authorized batch in one resumable program. Approval defaults and the eight
39
+ MCP tools are unchanged. No configuration or storage migration is required;
40
+ deployments without the operator UI can ignore the page changes. OAuth starts
41
+ abort downstream work after 30 seconds, but uncancellable storage resets and
42
+ cleanup must finish before the response. Restart reuses a matching client
43
+ registration; a revoked client may still fail at consent or callback and need
44
+ another restart. The Node template now pins 0.26.1.
45
+
46
+ ### Added
47
+
48
+ - **Continue or restart an operator OAuth start.** `POST /ui/oauth/<id>`
49
+ takes `?mode=continue` or `?mode=restart`. `restart`, still the default
50
+ when `mode` is absent, creates a new epoch and clears the grant and
51
+ discovery while retaining a matching issuer-bound client registration. `continue` hands back the pending
52
+ authorization URL when it was written in the last ten minutes. Otherwise it
53
+ starts a flow in the current epoch, reusing a stored registration when one
54
+ exists. A first connection still registers dynamically. A disconnected
55
+ connector resets first. A response with a URL now carries `reused`. A continue that reused a
56
+ URL or found the connection healthy leaves the cached catalog alone. Any
57
+ other `mode` is a 400. After a `publicUrl` change, continue keeps a client
58
+ registered for the old callback, which the authorization server refuses;
59
+ restart recovers. `ConnectorStatus` gains the optional
60
+ `authorizationReused`, set by `remoteMcp()`'s `startAuth`.
61
+
62
+ ### Changed
63
+
64
+ - **Pending authorization URLs expire for reuse.** A non-forced `startAuth`,
65
+ including `authorize_connector` without `force`, reissues a pending URL only
66
+ when it is under ten minutes old. The pending URL's envelope now records its
67
+ write time, which older readers ignore. A URL written before this release,
68
+ or under a pre-epoch generation, starts a fresh flow instead.
69
+ - **OAuth cleanup deletes run concurrently.** A restart's epoch cleanup,
70
+ `clearPending`, and `invalidateCredentials("all")` keep up to six deletes
71
+ in flight, the Workers limit on simultaneous connections. Every delete is
72
+ still attempted, and a falsy rejection now counts as a failure. A retired
73
+ epoch's manifest is still removed only after all of its values. Catalog
74
+ invalidation deletes the root and its chunks together under the registry's
75
+ chunk I/O bound, so a failed root delete no longer skips the chunks.
76
+
77
+ - Every page connecta renders for a person now shares one token layer and one
78
+ layout: the operator shell, the OAuth callback, a browser's 404, and the
79
+ artifact frame follow `branding.theme` and light or dark. The OAuth callback
80
+ names the outcome in its heading with a status mark, names the connector by
81
+ title only after the state check, uses `branding.productName` in its copy,
82
+ and folds the agent fix prompt under "Details for the operator". Refusals
83
+ stay byte-identical across connectors and paths.
84
+ - A request whose `Accept` names `text/html` gets a themed 404 page; every
85
+ other client keeps the plain `Not Found` body.
86
+ - Markdown artifacts take the deployment's resolved scheme and tokens instead
87
+ of the OS palette, and drop an opening `# Heading` that only repeats the
88
+ title the viewer already shows. The frame waits on the viewer's surface with
89
+ a loading line, and says so if the page never arrives.
90
+ - Without the operator UI, callback and 404 pages no longer link the default
91
+ `/favicon.svg`, which only the UI serves.
92
+ - The operator UI starts OAuth in one click. Connect opens the provider's
93
+ page in a new tab straight away and asks the server to continue a pending
94
+ authorization rather than restart it; only Reconnect on a healthy
95
+ connection restarts, behind an in-page confirm. A tab returning to the page
96
+ quietly re-reads the status of connectors waiting on authorization.
97
+ - Operator UI notices appear in the card that caused them, in fixed
98
+ sentences rather than the route's error text; problems fixed by authorizing
99
+ or adding a credential are warnings rather than errors, with one primary
100
+ action per row. Activity and artifact states, the confirm dialogs, the
101
+ mobile masthead, and the vocabulary shown to people (outcomes, actors,
102
+ timestamps) now read the same across pages and use `branding.productName`.
103
+
104
+ ### Fixed
105
+
106
+ - Operator OAuth starts now abort downstream discovery and registration after
107
+ 30 seconds or browser cancellation, with a fixed timeout response. Every
108
+ reset already started by the request, including issuer-mismatch recovery,
109
+ drains before catalog invalidation and response so a delayed generation
110
+ write cannot replace a later flow. These uncancellable storage waits can
111
+ exceed the deadline. Disconnect still finishes after browser cancellation.
112
+ - Forced OAuth restarts reuse registrations bound to the issuer, redirect URI,
113
+ client metadata, connector settings, and owner partition. Credentials are
114
+ re-sealed for the replacement epoch, and fresh discovery detects issuer
115
+ changes. Disconnect and issuer-mismatch recovery discard the registration.
116
+ The SDK cannot detect a revoked client while constructing a consent URL;
117
+ a callback refusal clears it so the next restart can register again.
118
+ - Agent sampling advice now applies to reads. For a one-time write, agents
119
+ reduce the full result in the program or page a direct-call result through
120
+ `get_result`, without repeating the write to recover discarded output.
121
+ - Authorized batches stay in one resumable program. Guidance explains how
122
+ tool-scoped approval covers repeated calls to one address while other
123
+ approval-required writes retain their own approvals. The call-scoped
124
+ default and host enforcement are unchanged.
125
+ - **A restart's cleanup no longer grows with every earlier restart.** Each
126
+ OAuth restart re-deleted every epoch the connector had ever retired, one key
127
+ at a time, and restart 1,001 failed forever with a full cleanup backlog. A
128
+ restart now deletes the epoch it retires, retries any of the eight most
129
+ recent epochs whose cleanup failed (their manifest outlives their values),
130
+ and sweeps at most 16 epochs retired more than 24 hours ago, dropping each
131
+ from the lineage only when all of its keys are gone. A Disconnect that
132
+ reported a failed cleanup still deletes the old grant when retried. The
133
+ cleanup work stays bounded independently of earlier resets. Residue a late
134
+ writer leaves in a younger epoch stays unreadable behind the fence until the
135
+ sweep reaches it. A late writer whose cleanup fails in an epoch already
136
+ listed now restarts that epoch's grace. The accepted assumption is that no
137
+ request holds a retired epoch for a day. The lineage records retirement
138
+ times in a sibling record older releases ignore, and its cap rises to 5,000
139
+ epochs. A connector stuck at the old 1,000 wall restarts the day it
140
+ upgrades. Rolling back is clean unless a lineage has grown past 1,000. See
141
+ [auth](./documentation/auth.md#starting-restarting-and-retiring-an-oauth-epoch).
142
+ - A failed or unreachable `/ui/data` no longer signs the operator out. Only a
143
+ 401 or 403 on that read returns to the token gate; anything else keeps the
144
+ page and offers Retry. A connector whose details fail to load says whether
145
+ the session, the browser's connection, or the downstream service is at
146
+ fault, and only the last offers the fix prompt.
147
+
7
148
  ## 0.26.0 — 2026-09-25
8
149
 
9
150
  Programs can now write with host approval. When a program reaches a tool that
package/README.md CHANGED
@@ -132,3 +132,14 @@ breaks and what a deployment can ignore.
132
132
  Built for its author's deployments first and published openly. Breaking
133
133
  changes are expected before 1.0. See the [changelog](./CHANGELOG.md) and
134
134
  [security policy](./SECURITY.md).
135
+
136
+ ### Existing client tokens
137
+
138
+ Upgrading from v0.23 does not require rotating managed `cta_…` tokens. Import
139
+ `accessTokens` from `@zackbart/connecta/auth/access-tokens`, replace the old
140
+ `accessTokens: true` with `accessTokens: accessTokens(storage)`, and keep the
141
+ same persistent storage namespace and identity/tool/pool grant rules. Enable
142
+ `identity.accessTokenManagement` only for the interactive operators who should
143
+ manage tokens. New issuance needs storage with atomic `compareAndSet`; older
144
+ storage adapters can still verify existing tokens. See the package's
145
+ `documentation/auth.md` for the migration and storage requirements.
@@ -0,0 +1,38 @@
1
+ import type { IdentityReference, InboundAuth, KVStorage } from "./types.js";
2
+ import type { AccessTokensModule } from "./module-contracts.js";
3
+ export interface AccessTokenMetadata {
4
+ id: string;
5
+ name: string;
6
+ tokenPrefix: string;
7
+ createdAt: string;
8
+ revokedAt?: string;
9
+ }
10
+ export interface CreatedAccessToken {
11
+ token: string;
12
+ accessToken: AccessTokenMetadata;
13
+ }
14
+ /**
15
+ * Deployment-scoped personal access tokens. Secret material is never
16
+ * recoverable: authentication indexes a SHA-256 digest of a random 256-bit
17
+ * token, while separately enumerable metadata powers operator management.
18
+ */
19
+ export declare class AccessTokenManager {
20
+ private readonly storage;
21
+ readonly auth: InboundAuth;
22
+ private readonly maxActive;
23
+ constructor(storage: KVStorage, options?: {
24
+ maxActive?: number;
25
+ });
26
+ private read;
27
+ list(): Promise<AccessTokenMetadata[]>;
28
+ create(name: unknown, createdBy: string | IdentityReference): Promise<CreatedAccessToken>;
29
+ rename(id: string, name: unknown): Promise<AccessTokenMetadata | null>;
30
+ revoke(id: string, revokedBy: string): Promise<AccessTokenMetadata | null>;
31
+ private updateRecord;
32
+ private updateActive;
33
+ private authorize;
34
+ }
35
+ /** Opt in using the same storage namespace that held the v0.23 records. */
36
+ export declare function accessTokens(storage: KVStorage, options?: {
37
+ maxActive?: number;
38
+ }): AccessTokensModule;
@@ -0,0 +1,294 @@
1
+ import { routeAccessTokens } from "./routes/access-tokens.js";
2
+ import { validIdentityReference } from "./identity.js";
3
+ const TOKEN_PREFIX = "cta_";
4
+ const TOKEN_BYTES = 32;
5
+ const TOKEN_VALUE_RE = /^cta_[A-Za-z0-9_-]{43}$/;
6
+ const RECORD_PREFIX = "access-token:v1:record:";
7
+ const LOOKUP_PREFIX = "access-token:v1:lookup:";
8
+ const MAX_NAME_CHARACTERS = 80;
9
+ const DEFAULT_MAX_ACTIVE = 100;
10
+ const MAX_CONFIGURED_ACTIVE = 1_000;
11
+ const ACTIVE_KEY = "access-token:v1:active";
12
+ const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
13
+ const encoder = new TextEncoder();
14
+ function recordKey(id) {
15
+ return `${RECORD_PREFIX}${id}`;
16
+ }
17
+ function lookupKey(hash) {
18
+ return `${LOOKUP_PREFIX}${hash}`;
19
+ }
20
+ function bytesToBase64Url(bytes) {
21
+ let binary = "";
22
+ for (const byte of bytes)
23
+ binary += String.fromCharCode(byte);
24
+ return btoa(binary)
25
+ .replaceAll("+", "-")
26
+ .replaceAll("/", "_")
27
+ .replace(/=+$/u, "");
28
+ }
29
+ function bytesToHex(bytes) {
30
+ return [...bytes]
31
+ .map((byte) => byte.toString(16).padStart(2, "0"))
32
+ .join("");
33
+ }
34
+ async function hashToken(token) {
35
+ return bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(token))));
36
+ }
37
+ function normalizeName(value) {
38
+ if (typeof value !== "string") {
39
+ throw new Error("Token name must be a string");
40
+ }
41
+ const compact = value.replace(/\s+/gu, " ").trim();
42
+ if (!compact)
43
+ throw new Error("Token name cannot be empty");
44
+ if (Array.from(compact).length > MAX_NAME_CHARACTERS) {
45
+ throw new Error(`Token name cannot exceed ${MAX_NAME_CHARACTERS} characters`);
46
+ }
47
+ return compact;
48
+ }
49
+ function parseRecord(raw) {
50
+ try {
51
+ const value = JSON.parse(raw);
52
+ if (value.version !== 1 ||
53
+ typeof value.id !== "string" ||
54
+ !ID_RE.test(value.id) ||
55
+ typeof value.name !== "string" ||
56
+ !value.name.trim() ||
57
+ Array.from(value.name).length > MAX_NAME_CHARACTERS ||
58
+ typeof value.tokenHash !== "string" ||
59
+ !/^[0-9a-f]{64}$/u.test(value.tokenHash) ||
60
+ typeof value.tokenPrefix !== "string" ||
61
+ !/^cta_[A-Za-z0-9_-]{8}$/u.test(value.tokenPrefix) ||
62
+ typeof value.createdAt !== "string" ||
63
+ !Number.isFinite(Date.parse(value.createdAt)) ||
64
+ typeof value.createdBy !== "string" ||
65
+ (value.principal !== undefined &&
66
+ !validIdentityReference(value.principal)) ||
67
+ (value.revokedAt !== undefined &&
68
+ (typeof value.revokedAt !== "string" || !value.revokedAt || !Number.isFinite(Date.parse(value.revokedAt)))) ||
69
+ (value.revokedBy !== undefined &&
70
+ typeof value.revokedBy !== "string")) {
71
+ throw new Error("invalid token record");
72
+ }
73
+ return value;
74
+ }
75
+ catch {
76
+ throw new Error("Stored access token metadata is invalid or corrupted");
77
+ }
78
+ }
79
+ function parseLookup(raw) {
80
+ try {
81
+ const value = JSON.parse(raw);
82
+ return value.version === 1 && typeof value.id === "string" && ID_RE.test(value.id)
83
+ ? { version: 1, id: value.id }
84
+ : null;
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ function metadata(record) {
91
+ return {
92
+ id: record.id,
93
+ name: record.name,
94
+ tokenPrefix: record.tokenPrefix,
95
+ createdAt: record.createdAt,
96
+ ...(record.revokedAt ? { revokedAt: record.revokedAt } : {}),
97
+ };
98
+ }
99
+ function unauthorized() {
100
+ return {
101
+ ok: false,
102
+ response: new Response(JSON.stringify({ error: "unauthorized" }), {
103
+ status: 401,
104
+ headers: {
105
+ "Content-Type": "application/json",
106
+ "WWW-Authenticate": "Bearer",
107
+ },
108
+ }),
109
+ };
110
+ }
111
+ /**
112
+ * Deployment-scoped personal access tokens. Secret material is never
113
+ * recoverable: authentication indexes a SHA-256 digest of a random 256-bit
114
+ * token, while separately enumerable metadata powers operator management.
115
+ */
116
+ export class AccessTokenManager {
117
+ storage;
118
+ auth;
119
+ maxActive;
120
+ constructor(storage, options = {}) {
121
+ this.storage = storage;
122
+ if (!storage.list) {
123
+ throw new Error("accessTokens requires a storage adapter that implements list(prefix)");
124
+ }
125
+ const maxActive = options.maxActive ?? DEFAULT_MAX_ACTIVE;
126
+ if (!Number.isInteger(maxActive) ||
127
+ maxActive < 1 ||
128
+ maxActive > MAX_CONFIGURED_ACTIVE) {
129
+ throw new Error(`accessTokens.maxActive must be a whole number from 1 to ${MAX_CONFIGURED_ACTIVE}`);
130
+ }
131
+ this.maxActive = maxActive;
132
+ this.auth = {
133
+ kind: "access_token",
134
+ activityActorNamespace: "connecta:access-tokens:v1",
135
+ activityActorLabel: async (id) => {
136
+ try {
137
+ return (await this.read(id))?.name;
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ },
143
+ authorize: (request) => this.authorize(request).catch(() => unauthorized()),
144
+ };
145
+ }
146
+ async read(id) {
147
+ if (!ID_RE.test(id))
148
+ return null;
149
+ const raw = await this.storage.get(recordKey(id));
150
+ const record = raw ? parseRecord(raw) : null;
151
+ if (record && record.id !== id)
152
+ throw new Error("Stored access token id mismatch");
153
+ return record;
154
+ }
155
+ async list() {
156
+ const keys = await this.storage.list(RECORD_PREFIX);
157
+ const records = await Promise.all(keys.map(async (key) => {
158
+ const raw = await this.storage.get(key);
159
+ return raw ? parseRecord(raw) : null;
160
+ }));
161
+ return records
162
+ .filter((record) => Boolean(record))
163
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
164
+ .map(metadata);
165
+ }
166
+ async create(name, createdBy) {
167
+ const normalizedName = normalizeName(name);
168
+ if (!this.storage.compareAndSet) {
169
+ throw new Error("Creating access tokens requires atomic compareAndSet storage");
170
+ }
171
+ if (typeof createdBy !== "string" && !validIdentityReference(createdBy)) {
172
+ throw new Error("Invalid token owner");
173
+ }
174
+ const secretBytes = crypto.getRandomValues(new Uint8Array(TOKEN_BYTES));
175
+ const token = TOKEN_PREFIX + bytesToBase64Url(secretBytes);
176
+ const hash = await hashToken(token);
177
+ if (await this.storage.get(lookupKey(hash))) {
178
+ throw new Error("Access token collision; create another token");
179
+ }
180
+ const record = {
181
+ version: 1,
182
+ id: crypto.randomUUID(),
183
+ name: normalizedName,
184
+ tokenHash: hash,
185
+ tokenPrefix: token.slice(0, 12),
186
+ createdAt: new Date().toISOString(),
187
+ createdBy: typeof createdBy === "string"
188
+ ? createdBy
189
+ : `${createdBy.namespace}:${createdBy.id}`,
190
+ ...(typeof createdBy === "string"
191
+ ? {}
192
+ : { principal: { ...createdBy } }),
193
+ };
194
+ await this.updateActive(record.id, true);
195
+ await this.storage.set(recordKey(record.id), JSON.stringify(record));
196
+ // A failed lookup write may have committed. Keep its reservation and
197
+ // metadata, so a manager can revoke it without ever returning the secret.
198
+ await this.storage.set(lookupKey(hash), JSON.stringify({ version: 1, id: record.id }));
199
+ return { token, accessToken: metadata(record) };
200
+ }
201
+ async rename(id, name) {
202
+ const normalized = normalizeName(name);
203
+ const record = await this.updateRecord(id, current => ({ ...current, name: normalized }));
204
+ return record ? metadata(record) : null;
205
+ }
206
+ async revoke(id, revokedBy) {
207
+ const record = await this.read(id);
208
+ if (!record)
209
+ return null;
210
+ // Remove admission first. A metadata failure cannot leave a successful
211
+ // revocation's lookup alive. Retry deletion even if already marked revoked.
212
+ await this.storage.delete(lookupKey(record.tokenHash));
213
+ const updated = await this.updateRecord(id, current => current.revokedAt ? current : {
214
+ ...current, revokedAt: new Date().toISOString(), revokedBy,
215
+ });
216
+ if (this.storage.compareAndSet)
217
+ await this.updateActive(id, false);
218
+ return updated ? metadata(updated) : null;
219
+ }
220
+ async updateRecord(id, update) {
221
+ if (!ID_RE.test(id))
222
+ return null;
223
+ for (let attempt = 0; attempt < 32; attempt++) {
224
+ const raw = await this.storage.get(recordKey(id));
225
+ if (!raw)
226
+ return null;
227
+ const record = parseRecord(raw);
228
+ if (record.id !== id)
229
+ throw new Error("Stored access token id mismatch");
230
+ const next = update(record);
231
+ if (this.storage.compareAndSet) {
232
+ // A rename racing revocation must retain revokedAt, including when
233
+ // an in-flight create has not yet published its lookup.
234
+ if (!await this.storage.compareAndSet(recordKey(id), raw, JSON.stringify(next)))
235
+ continue;
236
+ }
237
+ else {
238
+ // Legacy adapters cannot create, so no late lookup writer exists.
239
+ // Revocation's deleted lookup still refuses a stale metadata write.
240
+ await this.storage.set(recordKey(id), JSON.stringify(next));
241
+ }
242
+ return next;
243
+ }
244
+ throw new Error("Access token metadata is busy; retry the operation");
245
+ }
246
+ async updateActive(id, adding) {
247
+ const cas = this.storage.compareAndSet;
248
+ if (!cas)
249
+ throw new Error("Creating access tokens requires atomic compareAndSet storage");
250
+ for (let attempt = 0; attempt < 32; attempt++) {
251
+ const raw = await this.storage.get(ACTIVE_KEY);
252
+ const ids = raw === null
253
+ ? (await this.list()).filter(token => !token.revokedAt).map(token => token.id)
254
+ : JSON.parse(raw);
255
+ if (!Array.isArray(ids) || !ids.every(value => typeof value === "string" && ID_RE.test(value)) || new Set(ids).size !== ids.length) {
256
+ throw new Error("Stored access token capacity is invalid");
257
+ }
258
+ if (adding && ids.length >= this.maxActive)
259
+ throw new Error(`This deployment already has the maximum of ${this.maxActive} active access tokens`);
260
+ const next = adding ? [...ids, id] : ids.filter(value => value !== id);
261
+ if (await cas.call(this.storage, ACTIVE_KEY, raw, JSON.stringify(next)))
262
+ return;
263
+ }
264
+ throw new Error("Access token capacity is busy; retry the operation");
265
+ }
266
+ async authorize(request) {
267
+ const header = request.headers.get("authorization") ?? "";
268
+ const match = /^Bearer\s+(.+)$/iu.exec(header);
269
+ const token = match?.[1];
270
+ if (!token || !TOKEN_VALUE_RE.test(token))
271
+ return unauthorized();
272
+ const hash = await hashToken(token);
273
+ const lookupRaw = await this.storage.get(lookupKey(hash));
274
+ if (!lookupRaw)
275
+ return unauthorized();
276
+ const lookup = parseLookup(lookupRaw);
277
+ if (!lookup)
278
+ return unauthorized();
279
+ const record = await this.read(lookup.id);
280
+ if (!record || record.revokedAt || record.tokenHash !== hash || record.tokenPrefix !== token.slice(0, 12)) {
281
+ return unauthorized();
282
+ }
283
+ return {
284
+ ok: true,
285
+ subjectId: record.id,
286
+ ...(record.principal ? { principal: { ...record.principal } } : {}),
287
+ };
288
+ }
289
+ }
290
+ /** Opt in using the same storage namespace that held the v0.23 records. */
291
+ export function accessTokens(storage, options = {}) {
292
+ const manager = new AccessTokenManager(storage, options);
293
+ return { auth: manager.auth, handle: context => routeAccessTokens(context, manager) };
294
+ }
@@ -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,