@zackbart/connecta 0.23.0 → 0.24.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/AGENTS.md +5 -0
- package/CHANGELOG.md +53 -0
- package/README.md +18 -10
- package/dist/activity-friction.d.ts +3 -0
- package/dist/activity-friction.js +19 -0
- package/dist/activity.d.ts +11 -2
- package/dist/activity.js +15 -19
- package/dist/auth/downstream-oauth.d.ts +2 -1
- package/dist/auth/downstream-oauth.js +10 -1
- package/dist/branding.d.ts +67 -0
- package/dist/branding.js +176 -0
- package/dist/connectors/remote-mcp.js +3 -5
- package/dist/credential-contract.d.ts +24 -0
- package/dist/credential-contract.js +1 -0
- package/dist/credential-rules.d.ts +85 -0
- package/dist/credential-rules.js +107 -0
- package/dist/credentials.d.ts +4 -100
- package/dist/credentials.js +3 -107
- package/dist/execute.d.ts +6 -0
- package/dist/execute.js +17 -7
- package/dist/index.d.ts +35 -56
- package/dist/index.js +34 -58
- package/dist/invocation.js +19 -4
- package/dist/meta-tools.d.ts +4 -0
- package/dist/meta-tools.js +8 -4
- package/dist/module-contracts.d.ts +19 -0
- package/dist/module-contracts.js +1 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +6 -3
- package/dist/operator-ui/view.d.ts +2 -18
- package/dist/operator-ui/view.js +3 -20
- package/dist/registry.d.ts +4 -1
- package/dist/registry.js +4 -6
- package/dist/routes/activity.js +1 -1
- package/dist/routes/credentials.js +5 -2
- package/dist/routes/mcp.js +13 -3
- package/dist/routes/oauth-management.d.ts +2 -0
- package/dist/routes/oauth-management.js +108 -0
- package/dist/routes/oauth.d.ts +0 -1
- package/dist/routes/oauth.js +21 -121
- package/dist/routes/shared.d.ts +23 -17
- package/dist/routes/shared.js +48 -44
- package/dist/routes/ui.js +36 -33
- package/dist/server.js +6 -26
- package/dist/types.d.ts +2 -0
- package/dist/ui.d.ts +15 -70
- package/dist/ui.js +176 -317
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +26 -17
- package/documentation/auth.md +61 -106
- package/documentation/cloudflare.md +1 -1
- package/documentation/code-mode.md +2 -2
- package/documentation/connectors.md +1 -1
- package/documentation/linear.md +1 -1
- package/documentation/meta-tools.md +6 -4
- package/documentation/mixpanel.md +1 -1
- package/documentation/notion.md +2 -2
- package/documentation/operations.md +14 -14
- package/documentation/operator-ui.md +82 -104
- package/documentation/optional-modules-upgrade.md +243 -0
- package/documentation/provider-conventions.md +5 -3
- package/documentation/revenuecat.md +1 -1
- package/documentation/storage-and-credentials.md +59 -40
- package/documentation/stripe.md +1 -1
- package/documentation/upgrading.md +33 -4
- package/ethos.md +22 -30
- package/examples/worker/AGENTS.md +3 -1
- package/examples/worker/README.md +68 -84
- package/examples/worker/src/d1-activity.ts +1 -1
- package/examples/worker/src/index.ts +11 -6
- package/package.json +17 -1
- package/templates/node/AGENTS.md +8 -6
- package/templates/node/README.md +56 -67
- package/templates/node/package.json +1 -1
- package/templates/node/src/file-activity.ts +1 -1
- package/templates/node/src/index.ts +11 -12
- package/dist/access-tokens.d.ts +0 -31
- package/dist/access-tokens.js +0 -236
- package/dist/routes/access-tokens.d.ts +0 -6
- package/dist/routes/access-tokens.js +0 -83
package/templates/node/README.md
CHANGED
|
@@ -44,79 +44,72 @@ So commit the `package-lock.json` that the `npm install` above wrote on this
|
|
|
44
44
|
machine: from then on the build context carries it and every build takes the
|
|
45
45
|
reproducible `npm ci` path.
|
|
46
46
|
|
|
47
|
-
##
|
|
47
|
+
## Select optional modules
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
in `.env`, restart. Do them in this order; the last two lean on the first, and
|
|
54
|
-
Credentials wants one thing more than a block, called out in step 2.
|
|
49
|
+
The template explicitly enables `ui: operatorUi()` from
|
|
50
|
+
`@zackbart/connecta/ui`. Open `http://localhost:8787/` and supply the configured
|
|
51
|
+
bearer to inspect Connections. Omit that option and import for an API-only
|
|
52
|
+
server. OAuth callbacks remain in core even with no UI.
|
|
55
53
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
access token — that would make one shared secret a deployment-admin key. An
|
|
59
|
-
interactive identity is what unlocks the actionable half:
|
|
54
|
+
Connection management needs an interactive identity. A configured bearer is a
|
|
55
|
+
client key and never authorizes browser credential mutations. To enable Clerk:
|
|
60
56
|
|
|
61
57
|
```sh
|
|
62
|
-
npm install @clerk/backend
|
|
58
|
+
npm install @clerk/backend
|
|
63
59
|
```
|
|
64
60
|
|
|
65
|
-
Set `CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY`,
|
|
66
|
-
import
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
61
|
+
Set `CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY`, enable the corresponding
|
|
62
|
+
`clerkAuth` import and auth entry in `src/index.ts`, and set `PUBLIC_URL`.
|
|
63
|
+
Enable Dynamic Client Registration on the Clerk instance if MCP clients should
|
|
64
|
+
sign in with OAuth. Connecta no longer issues named client access tokens; keep
|
|
65
|
+
the configured bearer only for clients that need it.
|
|
70
66
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
`authScope: "personal"`
|
|
75
|
-
|
|
76
|
-
|
|
67
|
+
Set the code-owned identity resolvers deliberately. `connectorAccess` governs
|
|
68
|
+
use; `credentialAdministration` permits shared-auth changes, and
|
|
69
|
+
`personalConnection` permits the signed-in principal's personal-auth changes.
|
|
70
|
+
Both management permissions default to none. Use `authScope: "personal"` for a
|
|
71
|
+
connector where each person should connect their own downstream account.
|
|
72
|
+
`activityAccess` separately selects readers of global activity.
|
|
77
73
|
|
|
78
|
-
|
|
74
|
+
### Credential vault
|
|
75
|
+
|
|
76
|
+
Import `encryptedCredentialVault` from `@zackbart/connecta/credentials`, then
|
|
77
|
+
set `vault: encryptedCredentialVault(storage, credentialKey)`. Set
|
|
79
78
|
`CONNECTA_CREDENTIAL_KEY` to a base64 32-byte AES key:
|
|
80
79
|
|
|
81
80
|
```sh
|
|
82
81
|
node -e "console.log(crypto.randomBytes(32).toString('base64'))"
|
|
83
82
|
```
|
|
84
83
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
the state file.
|
|
115
|
-
|
|
116
|
-
None of this changes what agents can reach. Operator pages manage the
|
|
117
|
-
authentication material behind capabilities this file already declares; the
|
|
118
|
-
connector set, the tool catalog, and its annotations are `src/index.ts`'s
|
|
119
|
-
business and stay that way.
|
|
84
|
+
Keep this key outside the state file. Losing it makes saved values unreadable;
|
|
85
|
+
upgrades must reuse it. The shipped `time` connector declares no credential
|
|
86
|
+
slot. Add `credential: { label: "API token" }` to an `api()` connector and read
|
|
87
|
+
it through `await ctx.credential?.get()`, or use a provider such as `notion()`
|
|
88
|
+
that declares its own slot. Authorized humans manage the slot inside that
|
|
89
|
+
connection on `/`; there is no separate Credentials tab.
|
|
90
|
+
|
|
91
|
+
A saved replacement takes effect on the next call. Connecta tests credentials
|
|
92
|
+
only on an explicit action and otherwise fails at use. Without a vault or UI,
|
|
93
|
+
static credential recovery reports unavailable instead of offering a dead link.
|
|
94
|
+
|
|
95
|
+
### Activity history and diagnostics
|
|
96
|
+
|
|
97
|
+
Import `activityHistory` from `@zackbart/connecta/activity` and wire the template's
|
|
98
|
+
`fileActivityStore` through `activity: activityHistory({ store })`. The Activity
|
|
99
|
+
tab appears for authorized readers when the store supports listing. Omit this
|
|
100
|
+
option and its store wiring to record no activity.
|
|
101
|
+
|
|
102
|
+
`src/file-activity.ts` belongs to the deployment. It appends payload-free events
|
|
103
|
+
and periodically retains the newest 5,000, allowing a small slack window between
|
|
104
|
+
rewrites. Docker stores the log on the state volume. It records no arguments,
|
|
105
|
+
results, generated code, or raw errors. Adjust retention in that file if needed.
|
|
106
|
+
|
|
107
|
+
Diagnostics are independent. Keep the default logger or provide your own;
|
|
108
|
+
`logger: "silent"` suppresses diagnostic output explicitly.
|
|
109
|
+
|
|
110
|
+
The UI displays connections and current permissions. Configuration still owns
|
|
111
|
+
the connector set, tool definitions, and access rules. There is no token tab,
|
|
112
|
+
team roster, or policy editor.
|
|
120
113
|
|
|
121
114
|
## Deployment contract
|
|
122
115
|
|
|
@@ -153,11 +146,7 @@ Connecta doctor passed: 1 connector(s), QuickJS executed, prescribed seven-tool
|
|
|
153
146
|
`QuickJS` is this deployment's sandbox, reported by the deployment itself —
|
|
154
147
|
swap the executor and doctor names the one that actually ran the program.
|
|
155
148
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
sign in at `/` and check that the pages you turned on are there: Tokens and
|
|
161
|
-
Activity once their blocks are uncommented, and Credentials once the vault has
|
|
162
|
-
a connector credential slot to show. The nav lists a page only when this
|
|
163
|
-
deployment can serve it, so an absent page is a report, not a fault.
|
|
149
|
+
Doctor verifies the MCP contract. Verify UI behavior separately: sign in at `/`,
|
|
150
|
+
confirm the visible connections and their permitted auth controls, and check
|
|
151
|
+
Activity only when you enabled a readable history store. A missing optional
|
|
152
|
+
feature should not leave a tab behind.
|
|
@@ -27,7 +27,7 @@ import type {
|
|
|
27
27
|
ActivityStore,
|
|
28
28
|
ToolCallActivityEvent,
|
|
29
29
|
} from "@zackbart/connecta";
|
|
30
|
-
import { InvalidActivityCursorError } from "@zackbart/connecta";
|
|
30
|
+
import { InvalidActivityCursorError } from "@zackbart/connecta/activity";
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Events allowed past `maxEvents` before the log is rewritten. Trimming on the
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { bearerToken } from "@zackbart/connecta/auth/bearer";
|
|
2
|
+
import { operatorUi } from "@zackbart/connecta/ui";
|
|
3
|
+
// import { encryptedCredentialVault } from "@zackbart/connecta/credentials";
|
|
4
|
+
// import { activityHistory } from "@zackbart/connecta/activity";
|
|
1
5
|
/**
|
|
2
6
|
* Prescribed Connecta deployment.
|
|
3
7
|
*
|
|
@@ -20,7 +24,7 @@
|
|
|
20
24
|
* CONNECTA_CREDENTIAL_KEY vault key, once the credentials block is on
|
|
21
25
|
* CONNECTA_ACTIVITY_FILE activity log, once the activity block is on
|
|
22
26
|
*/
|
|
23
|
-
import { api,
|
|
27
|
+
import { api, createConnecta } from "@zackbart/connecta";
|
|
24
28
|
import { fileStorage, listen } from "@zackbart/connecta/node";
|
|
25
29
|
import { quickJsExecutor } from "@zackbart/connecta/quickjs";
|
|
26
30
|
// Operator sign-in. Needs `npm install @clerk/backend` — it is an optional
|
|
@@ -74,7 +78,7 @@ const connecta = createConnecta({
|
|
|
74
78
|
// identity: {
|
|
75
79
|
// connectorAccess: ({ principal }) =>
|
|
76
80
|
// principal?.id === "user_admin" ? "all" : ["time"],
|
|
77
|
-
//
|
|
81
|
+
// activityAccess: ({ id }) => id === "user_admin",
|
|
78
82
|
// },
|
|
79
83
|
publicUrl,
|
|
80
84
|
// Required: model-written programs run in a bounded QuickJS child.
|
|
@@ -84,24 +88,19 @@ const connecta = createConnecta({
|
|
|
84
88
|
// with this key — so keep the key out of that file and out of source:
|
|
85
89
|
// node -e "console.log(crypto.randomBytes(32).toString('base64'))"
|
|
86
90
|
// Rotating a credential takes effect on the next call; no restart.
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
// Named, revocable Bearer tokens for MCP clients, issued at /tokens by a
|
|
90
|
-
// signed-in operator. Secrets are shown once; only their hashes are stored.
|
|
91
|
-
// Requires the Clerk block above — there is nobody to authorize issuance
|
|
92
|
-
// otherwise.
|
|
93
|
-
// accessTokens: {},
|
|
94
|
-
//
|
|
91
|
+
// vault: encryptedCredentialVault(storage, process.env.CONNECTA_CREDENTIAL_KEY!),
|
|
95
92
|
// Payload-free activity history at /activity: who called what, when, how
|
|
96
93
|
// long it took, and whether it worked. Never arguments, results, generated
|
|
97
94
|
// code, or raw error messages. Commented because retention is yours to
|
|
98
95
|
// choose — see src/file-activity.ts.
|
|
99
|
-
// activity: {
|
|
96
|
+
// activity: activityHistory({
|
|
100
97
|
// store: fileActivityStore(
|
|
101
98
|
// process.env.CONNECTA_ACTIVITY_FILE || "./.connecta-activity.jsonl",
|
|
102
99
|
// ),
|
|
103
100
|
// deploymentId: "production",
|
|
104
|
-
// },
|
|
101
|
+
// }),
|
|
102
|
+
ui: operatorUi(),
|
|
103
|
+
identity: { credentialAdministration: () => "all", personalConnection: () => "all" },
|
|
105
104
|
connectors: [
|
|
106
105
|
api("time", {
|
|
107
106
|
description: "Time — current timestamp",
|
package/dist/access-tokens.d.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { IdentityReference, InboundAuth, KVStorage } from "./types.js";
|
|
2
|
-
export interface AccessTokenMetadata {
|
|
3
|
-
id: string;
|
|
4
|
-
name: string;
|
|
5
|
-
tokenPrefix: string;
|
|
6
|
-
createdAt: string;
|
|
7
|
-
revokedAt?: string;
|
|
8
|
-
}
|
|
9
|
-
export interface CreatedAccessToken {
|
|
10
|
-
token: string;
|
|
11
|
-
accessToken: AccessTokenMetadata;
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* Deployment-scoped personal access tokens. Secret material is never
|
|
15
|
-
* recoverable: authentication indexes a SHA-256 digest of a random 256-bit
|
|
16
|
-
* token, while separately enumerable metadata powers operator management.
|
|
17
|
-
*/
|
|
18
|
-
export declare class AccessTokenManager {
|
|
19
|
-
private readonly storage;
|
|
20
|
-
readonly auth: InboundAuth;
|
|
21
|
-
private readonly maxActive;
|
|
22
|
-
constructor(storage: KVStorage, options?: {
|
|
23
|
-
maxActive?: number;
|
|
24
|
-
});
|
|
25
|
-
private read;
|
|
26
|
-
list(): Promise<AccessTokenMetadata[]>;
|
|
27
|
-
create(name: unknown, createdBy: string | IdentityReference): Promise<CreatedAccessToken>;
|
|
28
|
-
rename(id: string, name: unknown): Promise<AccessTokenMetadata | null>;
|
|
29
|
-
revoke(id: string, revokedBy: string): Promise<AccessTokenMetadata | null>;
|
|
30
|
-
private authorize;
|
|
31
|
-
}
|
package/dist/access-tokens.js
DELETED
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
import { validIdentityReference } from "./identity.js";
|
|
2
|
-
const TOKEN_PREFIX = "cta_";
|
|
3
|
-
const TOKEN_BYTES = 32;
|
|
4
|
-
const TOKEN_VALUE_RE = /^cta_[A-Za-z0-9_-]{43}$/;
|
|
5
|
-
const RECORD_PREFIX = "access-token:v1:record:";
|
|
6
|
-
const LOOKUP_PREFIX = "access-token:v1:lookup:";
|
|
7
|
-
const MAX_NAME_CHARACTERS = 80;
|
|
8
|
-
const DEFAULT_MAX_ACTIVE = 100;
|
|
9
|
-
const MAX_CONFIGURED_ACTIVE = 1_000;
|
|
10
|
-
const encoder = new TextEncoder();
|
|
11
|
-
function recordKey(id) {
|
|
12
|
-
return `${RECORD_PREFIX}${id}`;
|
|
13
|
-
}
|
|
14
|
-
function lookupKey(hash) {
|
|
15
|
-
return `${LOOKUP_PREFIX}${hash}`;
|
|
16
|
-
}
|
|
17
|
-
function bytesToBase64Url(bytes) {
|
|
18
|
-
let binary = "";
|
|
19
|
-
for (const byte of bytes)
|
|
20
|
-
binary += String.fromCharCode(byte);
|
|
21
|
-
return btoa(binary)
|
|
22
|
-
.replaceAll("+", "-")
|
|
23
|
-
.replaceAll("/", "_")
|
|
24
|
-
.replace(/=+$/u, "");
|
|
25
|
-
}
|
|
26
|
-
function bytesToHex(bytes) {
|
|
27
|
-
return [...bytes]
|
|
28
|
-
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
29
|
-
.join("");
|
|
30
|
-
}
|
|
31
|
-
async function hashToken(token) {
|
|
32
|
-
return bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(token))));
|
|
33
|
-
}
|
|
34
|
-
function normalizeName(value) {
|
|
35
|
-
if (typeof value !== "string") {
|
|
36
|
-
throw new Error("Token name must be a string");
|
|
37
|
-
}
|
|
38
|
-
const compact = value.replace(/\s+/gu, " ").trim();
|
|
39
|
-
if (!compact)
|
|
40
|
-
throw new Error("Token name cannot be empty");
|
|
41
|
-
if (Array.from(compact).length > MAX_NAME_CHARACTERS) {
|
|
42
|
-
throw new Error(`Token name cannot exceed ${MAX_NAME_CHARACTERS} characters`);
|
|
43
|
-
}
|
|
44
|
-
return compact;
|
|
45
|
-
}
|
|
46
|
-
function parseRecord(raw) {
|
|
47
|
-
try {
|
|
48
|
-
const value = JSON.parse(raw);
|
|
49
|
-
if (value.version !== 1 ||
|
|
50
|
-
typeof value.id !== "string" ||
|
|
51
|
-
!/^[0-9a-f-]{36}$/u.test(value.id) ||
|
|
52
|
-
typeof value.name !== "string" ||
|
|
53
|
-
typeof value.tokenHash !== "string" ||
|
|
54
|
-
!/^[0-9a-f]{64}$/u.test(value.tokenHash) ||
|
|
55
|
-
typeof value.tokenPrefix !== "string" ||
|
|
56
|
-
typeof value.createdAt !== "string" ||
|
|
57
|
-
typeof value.createdBy !== "string" ||
|
|
58
|
-
(value.principal !== undefined &&
|
|
59
|
-
!validIdentityReference(value.principal)) ||
|
|
60
|
-
(value.revokedAt !== undefined &&
|
|
61
|
-
typeof value.revokedAt !== "string") ||
|
|
62
|
-
(value.revokedBy !== undefined &&
|
|
63
|
-
typeof value.revokedBy !== "string")) {
|
|
64
|
-
throw new Error("invalid token record");
|
|
65
|
-
}
|
|
66
|
-
return value;
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
throw new Error("Stored access token metadata is invalid or corrupted");
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
function parseLookup(raw) {
|
|
73
|
-
try {
|
|
74
|
-
const value = JSON.parse(raw);
|
|
75
|
-
return value.version === 1 && typeof value.id === "string"
|
|
76
|
-
? { version: 1, id: value.id }
|
|
77
|
-
: null;
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
function metadata(record) {
|
|
84
|
-
return {
|
|
85
|
-
id: record.id,
|
|
86
|
-
name: record.name,
|
|
87
|
-
tokenPrefix: record.tokenPrefix,
|
|
88
|
-
createdAt: record.createdAt,
|
|
89
|
-
...(record.revokedAt ? { revokedAt: record.revokedAt } : {}),
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
function unauthorized() {
|
|
93
|
-
return {
|
|
94
|
-
ok: false,
|
|
95
|
-
response: new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
96
|
-
status: 401,
|
|
97
|
-
headers: {
|
|
98
|
-
"Content-Type": "application/json",
|
|
99
|
-
"WWW-Authenticate": "Bearer",
|
|
100
|
-
},
|
|
101
|
-
}),
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* Deployment-scoped personal access tokens. Secret material is never
|
|
106
|
-
* recoverable: authentication indexes a SHA-256 digest of a random 256-bit
|
|
107
|
-
* token, while separately enumerable metadata powers operator management.
|
|
108
|
-
*/
|
|
109
|
-
export class AccessTokenManager {
|
|
110
|
-
storage;
|
|
111
|
-
auth;
|
|
112
|
-
maxActive;
|
|
113
|
-
constructor(storage, options = {}) {
|
|
114
|
-
this.storage = storage;
|
|
115
|
-
if (!storage.list) {
|
|
116
|
-
throw new Error("accessTokens requires a storage adapter that implements list(prefix)");
|
|
117
|
-
}
|
|
118
|
-
const maxActive = options.maxActive ?? DEFAULT_MAX_ACTIVE;
|
|
119
|
-
if (!Number.isInteger(maxActive) ||
|
|
120
|
-
maxActive < 1 ||
|
|
121
|
-
maxActive > MAX_CONFIGURED_ACTIVE) {
|
|
122
|
-
throw new Error(`accessTokens.maxActive must be a whole number from 1 to ${MAX_CONFIGURED_ACTIVE}`);
|
|
123
|
-
}
|
|
124
|
-
this.maxActive = maxActive;
|
|
125
|
-
this.auth = {
|
|
126
|
-
kind: "access_token",
|
|
127
|
-
activityActorNamespace: "connecta:access-tokens:v1",
|
|
128
|
-
activityActorLabel: async (id) => {
|
|
129
|
-
try {
|
|
130
|
-
return (await this.read(id))?.name;
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
return undefined;
|
|
134
|
-
}
|
|
135
|
-
},
|
|
136
|
-
authorize: (request) => this.authorize(request),
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
async read(id) {
|
|
140
|
-
const raw = await this.storage.get(recordKey(id));
|
|
141
|
-
return raw ? parseRecord(raw) : null;
|
|
142
|
-
}
|
|
143
|
-
async list() {
|
|
144
|
-
const keys = await this.storage.list(RECORD_PREFIX);
|
|
145
|
-
const records = await Promise.all(keys.map(async (key) => {
|
|
146
|
-
const raw = await this.storage.get(key);
|
|
147
|
-
return raw ? parseRecord(raw) : null;
|
|
148
|
-
}));
|
|
149
|
-
return records
|
|
150
|
-
.filter((record) => Boolean(record))
|
|
151
|
-
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
152
|
-
.map(metadata);
|
|
153
|
-
}
|
|
154
|
-
async create(name, createdBy) {
|
|
155
|
-
const normalizedName = normalizeName(name);
|
|
156
|
-
const active = (await this.list()).filter((token) => !token.revokedAt);
|
|
157
|
-
if (active.length >= this.maxActive) {
|
|
158
|
-
throw new Error(`This deployment already has the maximum of ${this.maxActive} active access tokens`);
|
|
159
|
-
}
|
|
160
|
-
const secretBytes = crypto.getRandomValues(new Uint8Array(TOKEN_BYTES));
|
|
161
|
-
const token = TOKEN_PREFIX + bytesToBase64Url(secretBytes);
|
|
162
|
-
const hash = await hashToken(token);
|
|
163
|
-
if (await this.storage.get(lookupKey(hash))) {
|
|
164
|
-
throw new Error("Access token collision; create another token");
|
|
165
|
-
}
|
|
166
|
-
const record = {
|
|
167
|
-
version: 1,
|
|
168
|
-
id: crypto.randomUUID(),
|
|
169
|
-
name: normalizedName,
|
|
170
|
-
tokenHash: hash,
|
|
171
|
-
tokenPrefix: token.slice(0, 12),
|
|
172
|
-
createdAt: new Date().toISOString(),
|
|
173
|
-
createdBy: typeof createdBy === "string"
|
|
174
|
-
? createdBy
|
|
175
|
-
: `${createdBy.namespace}:${createdBy.id}`,
|
|
176
|
-
...(typeof createdBy === "string"
|
|
177
|
-
? {}
|
|
178
|
-
: { principal: { ...createdBy } }),
|
|
179
|
-
};
|
|
180
|
-
await this.storage.set(recordKey(record.id), JSON.stringify(record));
|
|
181
|
-
try {
|
|
182
|
-
await this.storage.set(lookupKey(hash), JSON.stringify({ version: 1, id: record.id }));
|
|
183
|
-
}
|
|
184
|
-
catch (error) {
|
|
185
|
-
await this.storage.delete(recordKey(record.id)).catch(() => { });
|
|
186
|
-
throw error;
|
|
187
|
-
}
|
|
188
|
-
return { token, accessToken: metadata(record) };
|
|
189
|
-
}
|
|
190
|
-
async rename(id, name) {
|
|
191
|
-
const record = await this.read(id);
|
|
192
|
-
if (!record)
|
|
193
|
-
return null;
|
|
194
|
-
record.name = normalizeName(name);
|
|
195
|
-
await this.storage.set(recordKey(id), JSON.stringify(record));
|
|
196
|
-
return metadata(record);
|
|
197
|
-
}
|
|
198
|
-
async revoke(id, revokedBy) {
|
|
199
|
-
const record = await this.read(id);
|
|
200
|
-
if (!record)
|
|
201
|
-
return null;
|
|
202
|
-
if (!record.revokedAt) {
|
|
203
|
-
// Admission disappears first. A metadata-write failure may leave the UI
|
|
204
|
-
// calling the record active, but can never leave a token labelled
|
|
205
|
-
// revoked while its lookup still admits requests.
|
|
206
|
-
await this.storage.delete(lookupKey(record.tokenHash));
|
|
207
|
-
record.revokedAt = new Date().toISOString();
|
|
208
|
-
record.revokedBy = revokedBy;
|
|
209
|
-
await this.storage.set(recordKey(id), JSON.stringify(record));
|
|
210
|
-
}
|
|
211
|
-
return metadata(record);
|
|
212
|
-
}
|
|
213
|
-
async authorize(request) {
|
|
214
|
-
const header = request.headers.get("authorization") ?? "";
|
|
215
|
-
const match = /^Bearer\s+(.+)$/iu.exec(header);
|
|
216
|
-
const token = match?.[1];
|
|
217
|
-
if (!token || !TOKEN_VALUE_RE.test(token))
|
|
218
|
-
return unauthorized();
|
|
219
|
-
const hash = await hashToken(token);
|
|
220
|
-
const lookupRaw = await this.storage.get(lookupKey(hash));
|
|
221
|
-
if (!lookupRaw)
|
|
222
|
-
return unauthorized();
|
|
223
|
-
const lookup = parseLookup(lookupRaw);
|
|
224
|
-
if (!lookup)
|
|
225
|
-
return unauthorized();
|
|
226
|
-
const record = await this.read(lookup.id);
|
|
227
|
-
if (!record || record.revokedAt || record.tokenHash !== hash) {
|
|
228
|
-
return unauthorized();
|
|
229
|
-
}
|
|
230
|
-
return {
|
|
231
|
-
ok: true,
|
|
232
|
-
subjectId: record.id,
|
|
233
|
-
...(record.principal ? { principal: { ...record.principal } } : {}),
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
}
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import { type RouteContext } from "./shared.js";
|
|
2
|
-
/**
|
|
3
|
-
* Interactive-operator lifecycle for deployment access tokens. The token itself
|
|
4
|
-
* is deliberately never an administrator credential and cannot reach here.
|
|
5
|
-
*/
|
|
6
|
-
export declare function routeAccessTokens(context: RouteContext): Promise<Response | null>;
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { authorizeUiAdmin, isSameOrigin, msg, privateJson, } from "./shared.js";
|
|
2
|
-
async function readName(request) {
|
|
3
|
-
if (!request.headers
|
|
4
|
-
.get("content-type")
|
|
5
|
-
?.toLowerCase()
|
|
6
|
-
.startsWith("application/json")) {
|
|
7
|
-
return {
|
|
8
|
-
ok: false,
|
|
9
|
-
response: privateJson({ error: "Content-Type must be application/json" }, { status: 415 }),
|
|
10
|
-
};
|
|
11
|
-
}
|
|
12
|
-
const raw = await request.text();
|
|
13
|
-
if (raw.length > 1_000) {
|
|
14
|
-
return {
|
|
15
|
-
ok: false,
|
|
16
|
-
response: privateJson({ error: "request body is too large" }, { status: 413 }),
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
try {
|
|
20
|
-
const body = JSON.parse(raw);
|
|
21
|
-
return { ok: true, name: body.name };
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
return {
|
|
25
|
-
ok: false,
|
|
26
|
-
response: privateJson({ error: "invalid JSON body" }, { status: 400 }),
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Interactive-operator lifecycle for deployment access tokens. The token itself
|
|
32
|
-
* is deliberately never an administrator credential and cannot reach here.
|
|
33
|
-
*/
|
|
34
|
-
export async function routeAccessTokens(context) {
|
|
35
|
-
const match = /^\/ui\/access-tokens(?:\/([0-9a-f-]{36}))?$/.exec(context.path);
|
|
36
|
-
if (!match)
|
|
37
|
-
return null;
|
|
38
|
-
const { request, baseUrl, opts } = context;
|
|
39
|
-
if (request.method === "OPTIONS") {
|
|
40
|
-
return privateJson({ error: "CORS is not allowed" }, { status: 403 });
|
|
41
|
-
}
|
|
42
|
-
if (!opts.accessTokens) {
|
|
43
|
-
return privateJson({ error: "access token management is not configured" }, { status: 404 });
|
|
44
|
-
}
|
|
45
|
-
const mutating = request.method !== "GET";
|
|
46
|
-
if (mutating && !isSameOrigin(request, baseUrl)) {
|
|
47
|
-
return privateJson({ error: "same-origin request required" }, { status: 403 });
|
|
48
|
-
}
|
|
49
|
-
const admin = await authorizeUiAdmin(request, baseUrl, opts.auth, "access token management", context.runtimeContext, opts.identity);
|
|
50
|
-
if (!admin.ok)
|
|
51
|
-
return admin.response;
|
|
52
|
-
const id = match[1];
|
|
53
|
-
try {
|
|
54
|
-
if (!id && request.method === "GET") {
|
|
55
|
-
return privateJson({ accessTokens: await opts.accessTokens.list() });
|
|
56
|
-
}
|
|
57
|
-
if (!id && request.method === "POST") {
|
|
58
|
-
const input = await readName(request);
|
|
59
|
-
if (!input.ok)
|
|
60
|
-
return input.response;
|
|
61
|
-
return privateJson(await opts.accessTokens.create(input.name, admin.principal ?? admin.userId), { status: 201 });
|
|
62
|
-
}
|
|
63
|
-
if (id && request.method === "PUT") {
|
|
64
|
-
const input = await readName(request);
|
|
65
|
-
if (!input.ok)
|
|
66
|
-
return input.response;
|
|
67
|
-
const accessToken = await opts.accessTokens.rename(id, input.name);
|
|
68
|
-
return accessToken
|
|
69
|
-
? privateJson({ accessToken })
|
|
70
|
-
: privateJson({ error: "unknown access token" }, { status: 404 });
|
|
71
|
-
}
|
|
72
|
-
if (id && request.method === "DELETE") {
|
|
73
|
-
const accessToken = await opts.accessTokens.revoke(id, admin.userId);
|
|
74
|
-
return accessToken
|
|
75
|
-
? privateJson({ accessToken })
|
|
76
|
-
: privateJson({ error: "unknown access token" }, { status: 404 });
|
|
77
|
-
}
|
|
78
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
79
|
-
}
|
|
80
|
-
catch (error) {
|
|
81
|
-
return privateJson({ error: msg(error) }, { status: 400 });
|
|
82
|
-
}
|
|
83
|
-
}
|