@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.
- package/CHANGELOG.md +141 -0
- package/README.md +11 -0
- package/dist/access-tokens.d.ts +38 -0
- package/dist/access-tokens.js +294 -0
- package/dist/artifacts/document.js +6 -2
- package/dist/artifacts/markdown.js +43 -14
- package/dist/artifacts/routes.js +27 -4
- package/dist/artifacts/validate.js +1 -0
- package/dist/auth/downstream-oauth.js +397 -48
- package/dist/auth/oauth-start-reset.js +20 -0
- package/dist/branding.d.ts +87 -0
- package/dist/branding.js +170 -1
- package/dist/connectors/remote-mcp.js +32 -7
- package/dist/execute.js +7 -8
- package/dist/identity.js +2 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +13 -2
- package/dist/module-contracts.d.ts +5 -0
- package/dist/oauth-callback-outcome.js +43 -18
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +1 -0
- package/dist/operator-ui/view.js +304 -33
- package/dist/page-styles.js +7 -0
- package/dist/registry.js +16 -13
- package/dist/routes/access-tokens.js +110 -0
- package/dist/routes/mcp.js +5 -1
- package/dist/routes/oauth-management.js +66 -12
- package/dist/routes/oauth.js +54 -87
- package/dist/routes/shared.d.ts +3 -1
- package/dist/routes/shared.js +6 -1
- package/dist/routes/ui.js +9 -1
- package/dist/server.js +8 -5
- package/dist/skills.js +6 -6
- package/dist/types.d.ts +5 -0
- package/dist/ui.d.ts +6 -1
- package/dist/ui.js +27 -64
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +19 -9
- package/documentation/auth.md +166 -2
- package/documentation/code-mode.md +15 -15
- package/documentation/meta-tools.md +12 -5
- package/ethos.md +1 -1
- package/package.json +5 -1
- package/templates/node/README.md +11 -0
- package/templates/node/package.json +1 -1
- 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 === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c === '"' ? """ : "'");
|
|
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
|
-
|
|
434
|
-
|
|
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
|
|
437
|
-
"pre{overflow:auto;padding:16px;background
|
|
438
|
-
"blockquote{margin:0;padding:0 1em;color
|
|
439
|
-
"table{border-collapse:collapse;display:block;overflow:auto}th,td{padding:6px 12px;border:1px solid
|
|
440
|
-
"img{max-width:100%}hr{border:0;border-top:1px solid
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
}
|
package/dist/artifacts/routes.js
CHANGED
|
@@ -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
|
-
/**
|
|
24
|
-
|
|
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"
|
|
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
|
-
|
|
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,
|