@zackbart/connecta 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.md +15 -7
- package/bin/connecta.mjs +24 -6
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.d.ts +167 -23
- package/dist/access-tokens.js +676 -140
- package/dist/agent-identity.js +47 -0
- package/dist/auth/cloudflare-access.js +13 -5
- package/dist/call-admission-context.js +15 -0
- package/dist/call-admission.js +30 -43
- package/dist/call-recovery.js +6 -2
- package/dist/config-value-sources.js +11 -9
- package/dist/config.d.ts +37 -75
- package/dist/config.js +110 -36
- package/dist/connector-access.d.ts +4 -34
- package/dist/connector-access.js +15 -99
- package/dist/connector-caller.d.ts +7 -1
- package/dist/connector-caller.js +3 -2
- package/dist/connectors/api-connector.d.ts +27 -4
- package/dist/connectors/api-connector.js +2 -0
- package/dist/connectors/remote-mcp.js +2 -0
- package/dist/describe-config.d.ts +29 -7
- package/dist/describe-config.js +7 -17
- package/dist/index.d.ts +4 -3
- package/dist/index.js +29 -10
- package/dist/invocation.js +5 -3
- package/dist/membership.d.ts +51 -0
- package/dist/membership.js +121 -0
- package/dist/module-contracts.d.ts +23 -6
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -4
- package/dist/operator-ui/agents.d.ts +74 -0
- package/dist/operator-ui/agents.js +104 -0
- package/dist/operator-ui/contract.d.ts +149 -28
- package/dist/operator-ui/endpoints.js +192 -0
- package/dist/operator-ui/fix-prompts.js +4 -4
- package/dist/operator-ui/generated.js +5 -4
- package/dist/operator-ui/member-page.js +112 -0
- package/dist/operator-ui/model.d.ts +14 -6
- package/dist/operator-ui/view.d.ts +84 -19
- package/dist/operator-ui/view.js +82 -21
- package/dist/page-styles.js +1 -1
- package/dist/provider.d.ts +61 -8
- package/dist/provider.js +46 -7
- package/dist/providers/_shared/google/workspace.d.ts +53 -16
- package/dist/providers/_shared/google/workspace.js +30 -8
- package/dist/providers/_shared/rest/tools.js +2 -2
- package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
- package/dist/providers/_shared/rest/value-safety.js +699 -7
- package/dist/providers/ccb/index.js +1 -1
- package/dist/providers/cloudflare/index.js +3 -3
- package/dist/providers/cloudflare/rest.js +25 -3
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/cloudflare/value-safety.js +32 -0
- package/dist/providers/docs/index.js +3 -3
- package/dist/providers/docs/skill.generated.js +2 -2
- package/dist/providers/drive/index.js +3 -3
- package/dist/providers/drive/skill.generated.js +2 -2
- package/dist/providers/forms/index.js +3 -3
- package/dist/providers/forms/skill.generated.js +2 -2
- package/dist/providers/gmail/index.js +10 -8
- package/dist/providers/gmail/skill.generated.js +2 -2
- package/dist/providers/mixpanel/index.js +1 -1
- package/dist/providers/notion/index.d.ts +7 -0
- package/dist/providers/notion/index.js +100 -11
- package/dist/providers/notion/rest.js +45 -5
- package/dist/providers/notion/skill.generated.js +2 -2
- package/dist/providers/notion/value-safety.js +30 -5
- package/dist/providers/overflow/index.js +1 -1
- package/dist/providers/planning-center/index.js +1 -1
- package/dist/providers/sheets/index.d.ts +3 -0
- package/dist/providers/sheets/index.js +6 -3
- package/dist/providers/sheets/skill.generated.js +2 -2
- package/dist/providers/slides/index.js +3 -3
- package/dist/providers/slides/skill.generated.js +2 -2
- package/dist/providers/stripe/rest.js +9 -2
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/stripe/value-safety.js +22 -0
- package/dist/providers/vercel/rest.js +20 -2
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/value-safety.js +22 -0
- package/dist/registry.d.ts +2 -3
- package/dist/registry.js +8 -9
- package/dist/routes/access-tokens.js +82 -75
- package/dist/routes/activity-disclosure.js +17 -18
- package/dist/routes/activity.js +34 -54
- package/dist/routes/actor-labels.js +33 -4
- package/dist/routes/agent-path.js +43 -0
- package/dist/routes/connect.js +16 -12
- package/dist/routes/credentials.js +16 -40
- package/dist/routes/mcp.js +145 -95
- package/dist/routes/oauth-management.js +11 -5
- package/dist/routes/oauth.js +3 -9
- package/dist/routes/operator.js +24 -23
- package/dist/routes/shared.d.ts +60 -20
- package/dist/routes/shared.js +187 -96
- package/dist/routes/ui-config.js +437 -97
- package/dist/routes/ui-data.js +2 -2
- package/dist/routes/ui.js +9 -16
- package/dist/runtime/call-admission.js +9 -0
- package/dist/server.js +5 -0
- package/dist/storage/keys.js +9 -1
- package/dist/types.d.ts +11 -0
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +16 -14
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace-address.js +9 -0
- package/dist/workspace-subject.js +23 -0
- package/documentation/README.md +46 -22
- package/documentation/architecture.md +355 -50
- package/documentation/auth.md +775 -243
- package/documentation/code-mode.md +14 -11
- package/documentation/deploying.md +121 -24
- package/documentation/integrating.md +92 -21
- package/documentation/meta-tools.md +14 -9
- package/documentation/migration-0.33.md +344 -0
- package/documentation/operating.md +3 -1
- package/documentation/operator-tests.md +115 -28
- package/documentation/operator-ui.md +326 -61
- package/documentation/provider-auth-migration.md +37 -23
- package/documentation/provider-migration-0.29.md +2 -2
- package/examples/worker/AGENTS.md +30 -8
- package/examples/worker/README.md +92 -53
- package/examples/worker/src/connecta.config.ts +36 -20
- package/examples/worker/wrangler.jsonc +3 -0
- package/package.json +3 -2
- package/templates/node/.env.example +3 -0
- package/templates/node/AGENTS.md +27 -6
- package/templates/node/Dockerfile +2 -2
- package/templates/node/README.md +67 -42
- package/templates/node/docker-compose.yml +3 -2
- package/templates/node/package.json +4 -3
- package/templates/node/src/adopt-token.ts +23 -0
- package/templates/node/src/connecta.config.ts +28 -12
- package/templates/node/src/rotate-token.ts +17 -0
- package/templates/node/src/provision-token.ts +0 -17
package/dist/routes/activity.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { activityEventVisible } from "./activity-disclosure.js";
|
|
2
|
-
import {
|
|
2
|
+
import { Effect } from "effect";
|
|
3
3
|
import { activityBehaviorFacts, activityPackageVersion, activityClientFact, InvalidActivityCursorError, } from "../activity.js";
|
|
4
4
|
import { failureRecord, logFailure } from "../operator-record.js";
|
|
5
|
-
import {
|
|
5
|
+
import { actorKey, resolveActorLabels } from "./actor-labels.js";
|
|
6
6
|
import { authorized, refuse, serveOperator, visibleRegistry } from "./operator.js";
|
|
7
|
-
import { privateJson } from "./shared.js";
|
|
7
|
+
import { privateJson, readsActivity } from "./shared.js";
|
|
8
8
|
/**
|
|
9
9
|
* Add display-only actor labels to one authorized activity page. Resolution is
|
|
10
10
|
* best-effort, bounded, and read-time only: stored events retain stable ids and
|
|
@@ -15,50 +15,42 @@ import { privateJson } from "./shared.js";
|
|
|
15
15
|
* abandoned, as they are when the reader leaves.
|
|
16
16
|
*/
|
|
17
17
|
function enrichActivityActorLabels(page, auth) {
|
|
18
|
-
return Effect.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
...record,
|
|
45
|
-
...activityBehaviorFacts(event),
|
|
46
|
-
...(packageVersion !== undefined ? { packageVersion } : {}),
|
|
47
|
-
...(clientName !== undefined ? { clientName } : {}),
|
|
48
|
-
...(clientVersion !== undefined ? { clientVersion } : {}),
|
|
49
|
-
actor: resolved ? { ...actor, label: resolved } : actor,
|
|
50
|
-
};
|
|
51
|
-
}),
|
|
52
|
-
})));
|
|
53
|
-
});
|
|
18
|
+
return resolveActorLabels(page.events.map((event) => event.actor), auth).pipe(Effect.map((labels) => ({
|
|
19
|
+
...page,
|
|
20
|
+
events: page.events.map((event) => {
|
|
21
|
+
const resolved = event.actor.id ? labels.get(actorKey(event.actor)) : undefined;
|
|
22
|
+
// Never trust or echo a `label` supplied by storage. The persisted event
|
|
23
|
+
// schema has no label; only this authenticated read path may add one.
|
|
24
|
+
const actor = {
|
|
25
|
+
kind: event.actor.kind,
|
|
26
|
+
...(event.actor.id ? { id: event.actor.id } : {}),
|
|
27
|
+
...(event.actor.namespace ? { namespace: event.actor.namespace } : {}),
|
|
28
|
+
};
|
|
29
|
+
// Re-check stored client facts, including custom readers and old rows.
|
|
30
|
+
const { packageVersion: suppliedPackageVersion, clientName: suppliedName, clientVersion: suppliedVersion, classification: _classification, resultBytes: _resultBytes, kind: _kind, drift: _drift, pool: _pool, actorBasis: _actorBasis, ...record } = event;
|
|
31
|
+
const packageVersion = activityPackageVersion(suppliedPackageVersion);
|
|
32
|
+
const clientName = activityClientFact(suppliedName, "name");
|
|
33
|
+
const clientVersion = activityClientFact(suppliedVersion, "version");
|
|
34
|
+
return {
|
|
35
|
+
...record,
|
|
36
|
+
...activityBehaviorFacts(event),
|
|
37
|
+
...(packageVersion !== undefined ? { packageVersion } : {}),
|
|
38
|
+
...(clientName !== undefined ? { clientName } : {}),
|
|
39
|
+
...(clientVersion !== undefined ? { clientVersion } : {}),
|
|
40
|
+
actor: resolved ? { ...actor, label: resolved } : actor,
|
|
41
|
+
};
|
|
42
|
+
}),
|
|
43
|
+
})));
|
|
54
44
|
}
|
|
55
45
|
function activityRead(context) {
|
|
56
46
|
const { url, opts } = context;
|
|
57
47
|
return Effect.gen(function* () {
|
|
58
48
|
const authz = yield* authorized(context);
|
|
49
|
+
// A maintainer principal, interactive or not (#837); members and agents
|
|
50
|
+
// get the private 404 every operator route gives them.
|
|
51
|
+
if (!readsActivity(context.opts, authz))
|
|
52
|
+
return yield* refuse("not found", 404);
|
|
59
53
|
const registry = yield* visibleRegistry(context, authz);
|
|
60
|
-
if (!authz.operator)
|
|
61
|
-
return yield* refuse("activity access required", 403);
|
|
62
54
|
if (opts.config.activity?.readGate &&
|
|
63
55
|
!(yield* Effect.promise(async () => opts.config.activity?.readGate(authz.actor)))) {
|
|
64
56
|
return yield* refuse("forbidden", 403);
|
|
@@ -66,19 +58,7 @@ function activityRead(context) {
|
|
|
66
58
|
const list = opts.config.activity?.store.list?.bind(opts.config.activity?.store);
|
|
67
59
|
if (!list)
|
|
68
60
|
return yield* refuse("activity history is not configured", 404);
|
|
69
|
-
const
|
|
70
|
-
for (const [name, pool] of opts.pools) {
|
|
71
|
-
if (yield* Effect.promise(async () => {
|
|
72
|
-
try {
|
|
73
|
-
return (await pool.grant(authz.identity)) === true;
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
}))
|
|
79
|
-
admittedPools.add(name);
|
|
80
|
-
}
|
|
81
|
-
const visible = (event) => activityEventVisible(context, authz, registry, admittedPools, event);
|
|
61
|
+
const visible = (event) => activityEventVisible(context, authz, registry, event);
|
|
82
62
|
const cursor = url.searchParams.get("cursor") ?? undefined;
|
|
83
63
|
if (cursor && cursor.length > 500)
|
|
84
64
|
return yield* refuse("invalid cursor", 400);
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// read time from the one auth provider that owns the actor's directory, and
|
|
3
3
|
// only on an authorized read path. Nothing here is persisted, and a label a
|
|
4
4
|
// store happens to carry is never trusted or echoed.
|
|
5
|
+
import { Duration, Effect } from "effect";
|
|
5
6
|
import { activityActorNamespace } from "./shared.js";
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
const ACTOR_LABEL_CONCURRENCY = 8;
|
|
8
|
+
const ACTOR_LABEL_BUDGET_MS = 1_500;
|
|
8
9
|
const ACTOR_LABEL_MAX_LENGTH = 160;
|
|
9
|
-
|
|
10
|
+
function cleanActorLabel(value) {
|
|
10
11
|
if (typeof value !== "string")
|
|
11
12
|
return undefined;
|
|
12
13
|
const compact = value.replace(/\s+/gu, " ").trim();
|
|
@@ -22,7 +23,7 @@ export function actorKey(actor) {
|
|
|
22
23
|
* the actor's directory. An actor no provider can claim unambiguously gets no
|
|
23
24
|
* lookup at all.
|
|
24
25
|
*/
|
|
25
|
-
|
|
26
|
+
function labelLookups(actors, auth) {
|
|
26
27
|
const identities = new Map();
|
|
27
28
|
for (const actor of actors) {
|
|
28
29
|
if (!actor.id)
|
|
@@ -65,3 +66,31 @@ export function labelLookups(actors, auth) {
|
|
|
65
66
|
}
|
|
66
67
|
return lookups;
|
|
67
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve labels for these actors, keyed by `actorKey`. Best-effort and
|
|
71
|
+
* bounded: at most eight lookups run at once, and whatever has resolved
|
|
72
|
+
* within 1.5 seconds in all is used; the rest are abandoned, as they are when
|
|
73
|
+
* the reader leaves. A lookup that fails or answers nothing usable is absent.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveActorLabels(actors, auth) {
|
|
76
|
+
return Effect.suspend(() => {
|
|
77
|
+
const labels = new Map();
|
|
78
|
+
const resolve = ({ key, id, provider }) => Effect.tryPromise(() => Promise.resolve(provider.activityActorLabel(id))).pipe(Effect.map(cleanActorLabel), Effect.orElseSucceed(() => undefined), Effect.map((label) => {
|
|
79
|
+
if (label)
|
|
80
|
+
labels.set(key, label);
|
|
81
|
+
}));
|
|
82
|
+
return Effect.forEach(labelLookups(actors, auth), resolve, {
|
|
83
|
+
concurrency: ACTOR_LABEL_CONCURRENCY,
|
|
84
|
+
discard: true,
|
|
85
|
+
}).pipe(Effect.timeoutOption(Duration.millis(ACTOR_LABEL_BUDGET_MS)), Effect.map(() => labels));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The activity actor a listed person would be recorded as: the principal in
|
|
90
|
+
* the namespace of the interactive provider that owns that directory. People
|
|
91
|
+
* no interactive provider owns have no actor, and so no label.
|
|
92
|
+
*/
|
|
93
|
+
export function principalActor(principal, auth) {
|
|
94
|
+
const provider = auth.find((candidate) => candidate.interactiveOperator && activityActorNamespace(candidate) === principal.namespace);
|
|
95
|
+
return provider ? { kind: provider.kind, id: principal.id, namespace: principal.namespace } : undefined;
|
|
96
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Agent endpoint paths (#837, decisions/0006). Cloudflare Access bypasses
|
|
2
|
+
// `/agent/*` at the edge after resolving dot segments, folding case, decoding
|
|
3
|
+
// unreserved escapes, and collapsing a leading `//`, while the Worker sees
|
|
4
|
+
// the raw pathname. So connecta claims every path Access might treat as
|
|
5
|
+
// `/agent/*`, and admits only the one exact, raw spelling of a pool.
|
|
6
|
+
/** Pool names, as `createConnecta` validates them. */
|
|
7
|
+
const POOL_NAME_RE = /^[a-z0-9_-]+$/;
|
|
8
|
+
/** Escapes decoded before giving up and treating the path as an agent path. */
|
|
9
|
+
const MAX_DECODE_ROUNDS = 8;
|
|
10
|
+
/**
|
|
11
|
+
* The pool an agent endpoint names, or undefined. The raw pathname must be
|
|
12
|
+
* byte-equal to `/agent/` plus a well-formed pool name: no decoding, case
|
|
13
|
+
* folding, slash collapsing, trailing slash, extra segment, or `%`.
|
|
14
|
+
*/
|
|
15
|
+
export function agentEndpointPool(path) {
|
|
16
|
+
if (!path.startsWith("/agent/"))
|
|
17
|
+
return undefined;
|
|
18
|
+
const pool = path.slice("/agent/".length);
|
|
19
|
+
return POOL_NAME_RE.test(pool) ? pool : undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether a pathname belongs to the agent namespace: anything that starts
|
|
23
|
+
* with `/agent` once every escape is decoded, case is folded, and slashes are
|
|
24
|
+
* collapsed. This is deliberately wider than Access's own matching, so no
|
|
25
|
+
* path the edge bypasses can fall through to a human route.
|
|
26
|
+
*/
|
|
27
|
+
export function isAgentPath(path) {
|
|
28
|
+
let value = path;
|
|
29
|
+
for (let round = 0; value.includes("%"); round++) {
|
|
30
|
+
// Still encoded after the bound: refuse it with the agent 404.
|
|
31
|
+
if (round === MAX_DECODE_ROUNDS)
|
|
32
|
+
return true;
|
|
33
|
+
const decoded = value.replace(/%([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
34
|
+
if (decoded === value)
|
|
35
|
+
break;
|
|
36
|
+
value = decoded;
|
|
37
|
+
}
|
|
38
|
+
return value
|
|
39
|
+
.replace(/\\/g, "/")
|
|
40
|
+
.replace(/\/{2,}/g, "/")
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.startsWith("/agent");
|
|
43
|
+
}
|
package/dist/routes/connect.js
CHANGED
|
@@ -8,7 +8,7 @@ import { drainOAuthStartResets } from "../auth/oauth-start-reset.js";
|
|
|
8
8
|
import { finishOAuthConnectStart, consumeOAuthConnectLink, oauthConnectUnavailable, verifyOAuthHandoff, } from "../oauth-handoff.js";
|
|
9
9
|
import { failureRecord, logFailure } from "../operator-record.js";
|
|
10
10
|
import { runEdge, withDeadlineEffect } from "../runtime/run.js";
|
|
11
|
-
import { authorizeUiIdentity, mayManageConnector, privateJson,
|
|
11
|
+
import { admitsMaintainer, authorizeUiIdentity, mayManageConnector, privateJson, withSessionCookies, } from "./shared.js";
|
|
12
12
|
const START_TIMEOUT_MS = 30_000;
|
|
13
13
|
/** Clerk establishes a browser session on this origin before retrying the link. */
|
|
14
14
|
function clerkSignIn(context) {
|
|
@@ -84,33 +84,37 @@ async function connect(context) {
|
|
|
84
84
|
headers: { Location: target.href, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" },
|
|
85
85
|
});
|
|
86
86
|
};
|
|
87
|
-
const authz = await authorizeUiIdentity(request, baseUrl, opts.config.auth, "OAuth connection", runtimeContext
|
|
87
|
+
const authz = await authorizeUiIdentity(request, baseUrl, opts.config.auth, "OAuth connection", runtimeContext);
|
|
88
88
|
if (!authz.ok) {
|
|
89
89
|
// Access sign-in is enforced at the edge. Clerk needs its own sign-in page.
|
|
90
|
+
// The sign-in page returns to this link, which then sends a maintainer on
|
|
91
|
+
// to the operator UI. Pool members cannot use that UI yet (#837), so
|
|
92
|
+
// signing in there would strand them.
|
|
90
93
|
if (authz.response.status === 401 &&
|
|
91
94
|
authorizationCredential(request).kind === "absent" &&
|
|
92
95
|
!runtimeContext?.access) {
|
|
93
|
-
if (opts.config.ui)
|
|
94
|
-
return authPage();
|
|
95
96
|
const signIn = clerkSignIn(context);
|
|
96
97
|
if (signIn)
|
|
97
98
|
return signIn;
|
|
99
|
+
if (opts.config.ui)
|
|
100
|
+
return authPage();
|
|
98
101
|
}
|
|
99
102
|
return authz.response;
|
|
100
103
|
}
|
|
101
|
-
try {
|
|
102
|
-
validateAuthPermissions(authz, opts.registry);
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
return refuse("forbidden");
|
|
106
|
-
}
|
|
107
104
|
const connector = opts.registry.getConnector(id);
|
|
108
|
-
|
|
105
|
+
// Visibility is the caller's pools, or everything for a maintainer, so a
|
|
106
|
+
// member may connect a personal connector in a pool they belong to.
|
|
107
|
+
if (authz.principalKey !== handoff.principal || !mayManageConnector(opts, authz, connector)) {
|
|
109
108
|
return refuse("Sign in as the user who requested this connection and has permission to manage it.");
|
|
110
109
|
}
|
|
110
|
+
// The operator UI serves maintainers only until member views ship (#837).
|
|
111
|
+
// A member's OAuth link starts here directly, as it does without a UI.
|
|
112
|
+
const viaUi = Boolean(opts.config.ui) && admitsMaintainer(opts, authz);
|
|
111
113
|
if (!connector.startAuth) {
|
|
112
114
|
if (!connector.credential || !opts.config.ui || !opts.config.vault)
|
|
113
115
|
return refuse("unknown OAuth connector", 404);
|
|
116
|
+
if (!viaUi)
|
|
117
|
+
return refuse("Credential entry is in the operator UI, which only maintainers can use for now.");
|
|
114
118
|
let target;
|
|
115
119
|
try {
|
|
116
120
|
target = new URL(opts.config.ui.credentialHandoffUrl(baseUrl), baseUrl);
|
|
@@ -133,7 +137,7 @@ async function connect(context) {
|
|
|
133
137
|
},
|
|
134
138
|
}), authz.sessionCookies);
|
|
135
139
|
}
|
|
136
|
-
if (
|
|
140
|
+
if (viaUi && context.url.searchParams.get("start") !== "1")
|
|
137
141
|
return withSessionCookies(authPage(), authz.sessionCookies);
|
|
138
142
|
const registry = opts.registry.scoped({
|
|
139
143
|
connectorIds: [id],
|
|
@@ -2,45 +2,10 @@ import { failureRecord, logFailure } from "../operator-record.js";
|
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import { claimConnectorScopeCleanup, closeConnectorScope } from "../connector-scope.js";
|
|
4
4
|
import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape } from "../credential-rules.js";
|
|
5
|
-
import { authorizedPerson, refuse, serveOperator,
|
|
6
|
-
import { mayManageConnector, isSameOrigin, msg, privateJson } from "./shared.js";
|
|
5
|
+
import { authorizedPerson, refuse, serveOperator, managementRegistry } from "./operator.js";
|
|
6
|
+
import { boundedText, mayManageConnector, isSameOrigin, msg, privateJson } from "./shared.js";
|
|
7
7
|
/** The longest body, in characters, a credential write accepts. */
|
|
8
8
|
const MAX_BODY_CHARS = 20_000;
|
|
9
|
-
/**
|
|
10
|
-
* The most UTF-8 a body within MAX_BODY_CHARS can take. No UTF-16 unit costs
|
|
11
|
-
* more than three bytes, and a malformed sequence decodes to one U+FFFD per
|
|
12
|
-
* at most three bytes, so a body past this many bytes is past the character
|
|
13
|
-
* limit too — and reading stops there, instead of buffering whatever a client
|
|
14
|
-
* cares to send before measuring it.
|
|
15
|
-
*/
|
|
16
|
-
const MAX_BODY_BYTES = MAX_BODY_CHARS * 3;
|
|
17
|
-
/** The body as text, or undefined once it is longer than MAX_BODY_CHARS. */
|
|
18
|
-
async function boundedText(request) {
|
|
19
|
-
if (!request.body)
|
|
20
|
-
return "";
|
|
21
|
-
const reader = request.body.getReader();
|
|
22
|
-
const chunks = [];
|
|
23
|
-
let size = 0;
|
|
24
|
-
for (;;) {
|
|
25
|
-
const { done, value } = await reader.read();
|
|
26
|
-
if (done)
|
|
27
|
-
break;
|
|
28
|
-
size += value.byteLength;
|
|
29
|
-
if (size > MAX_BODY_BYTES) {
|
|
30
|
-
reader.cancel().catch(() => { });
|
|
31
|
-
return undefined;
|
|
32
|
-
}
|
|
33
|
-
chunks.push(value);
|
|
34
|
-
}
|
|
35
|
-
const bytes = new Uint8Array(size);
|
|
36
|
-
let offset = 0;
|
|
37
|
-
for (const chunk of chunks) {
|
|
38
|
-
bytes.set(chunk, offset);
|
|
39
|
-
offset += chunk.byteLength;
|
|
40
|
-
}
|
|
41
|
-
const text = new TextDecoder().decode(bytes);
|
|
42
|
-
return text.length > MAX_BODY_CHARS ? undefined : text;
|
|
43
|
-
}
|
|
44
9
|
/** Field-by-field checks, in words the Credentials page shows as written. */
|
|
45
10
|
function credentialInput(body, config) {
|
|
46
11
|
if (!config.fields?.length) {
|
|
@@ -73,7 +38,7 @@ function readCredentialInput(request, config) {
|
|
|
73
38
|
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
|
|
74
39
|
return yield* refuse("Content-Type must be application/json", 415);
|
|
75
40
|
}
|
|
76
|
-
const raw = yield* Effect.promise(() => boundedText(request));
|
|
41
|
+
const raw = yield* Effect.promise(() => boundedText(request, MAX_BODY_CHARS));
|
|
77
42
|
if (raw === undefined)
|
|
78
43
|
return yield* refuse("request body is too large", 413);
|
|
79
44
|
let body;
|
|
@@ -99,12 +64,12 @@ function credentialRequest(context, connectorId, action) {
|
|
|
99
64
|
return yield* refuse("same-origin request required", 403);
|
|
100
65
|
}
|
|
101
66
|
const authz = yield* authorizedPerson(context, "credential management");
|
|
102
|
-
const registry = yield*
|
|
67
|
+
const registry = yield* managementRegistry(context, authz);
|
|
103
68
|
const connector = registry.getConnector(connectorId);
|
|
104
69
|
const credential = connector?.credential;
|
|
105
70
|
if (!connector || !credential)
|
|
106
71
|
return yield* refuse("unknown credential slot", 404);
|
|
107
|
-
if (!mayManageConnector(authz, connector)) {
|
|
72
|
+
if (!mayManageConnector(opts, authz, connector)) {
|
|
108
73
|
return yield* refuse("credential management is not permitted", 403);
|
|
109
74
|
}
|
|
110
75
|
const personal = connector.authScope === "personal";
|
|
@@ -191,6 +156,17 @@ function credentialRequest(context, connectorId, action) {
|
|
|
191
156
|
}).pipe(Effect.catch((error) => refuse(msg(error), 400)));
|
|
192
157
|
}
|
|
193
158
|
if (request.method === "DELETE") {
|
|
159
|
+
// Clearing nothing is refused, as testing nothing is, so the route
|
|
160
|
+
// agrees with the operator contract, which offers `clear` only once a
|
|
161
|
+
// value is stored in the caller's partition (#837). A value that cannot
|
|
162
|
+
// be read may still be cleared: removing it is how one recovers.
|
|
163
|
+
const stored = yield* Effect.tryPromise({
|
|
164
|
+
try: () => vault.metadata(connectorId, owner),
|
|
165
|
+
catch: (error) => error,
|
|
166
|
+
}).pipe(Effect.orElseSucceed(() => "unreadable"));
|
|
167
|
+
if (stored === null) {
|
|
168
|
+
return privateJson({ error: "no credential is stored to clear", problem: "credential_required" }, { status: 409 });
|
|
169
|
+
}
|
|
194
170
|
yield* Effect.promise(() => vault.delete(connectorId, owner));
|
|
195
171
|
yield* Effect.promise(() => registry.invalidateStored(connectorId));
|
|
196
172
|
return new Response(null, {
|