@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.
Files changed (137) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +15 -7
  3. package/bin/connecta.mjs +24 -6
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.d.ts +167 -23
  6. package/dist/access-tokens.js +676 -140
  7. package/dist/agent-identity.js +47 -0
  8. package/dist/auth/cloudflare-access.js +13 -5
  9. package/dist/call-admission-context.js +15 -0
  10. package/dist/call-admission.js +30 -43
  11. package/dist/call-recovery.js +6 -2
  12. package/dist/config-value-sources.js +11 -9
  13. package/dist/config.d.ts +37 -75
  14. package/dist/config.js +110 -36
  15. package/dist/connector-access.d.ts +4 -34
  16. package/dist/connector-access.js +15 -99
  17. package/dist/connector-caller.d.ts +7 -1
  18. package/dist/connector-caller.js +3 -2
  19. package/dist/connectors/api-connector.d.ts +27 -4
  20. package/dist/connectors/api-connector.js +2 -0
  21. package/dist/connectors/remote-mcp.js +2 -0
  22. package/dist/describe-config.d.ts +29 -7
  23. package/dist/describe-config.js +7 -17
  24. package/dist/index.d.ts +4 -3
  25. package/dist/index.js +29 -10
  26. package/dist/invocation.js +5 -3
  27. package/dist/membership.d.ts +51 -0
  28. package/dist/membership.js +121 -0
  29. package/dist/module-contracts.d.ts +23 -6
  30. package/dist/operator-record.d.ts +2 -2
  31. package/dist/operator-record.js +2 -4
  32. package/dist/operator-ui/agents.d.ts +74 -0
  33. package/dist/operator-ui/agents.js +104 -0
  34. package/dist/operator-ui/contract.d.ts +149 -28
  35. package/dist/operator-ui/endpoints.js +192 -0
  36. package/dist/operator-ui/fix-prompts.js +4 -4
  37. package/dist/operator-ui/generated.js +5 -4
  38. package/dist/operator-ui/member-page.js +112 -0
  39. package/dist/operator-ui/model.d.ts +14 -6
  40. package/dist/operator-ui/view.d.ts +84 -19
  41. package/dist/operator-ui/view.js +82 -21
  42. package/dist/page-styles.js +1 -1
  43. package/dist/provider.d.ts +61 -8
  44. package/dist/provider.js +46 -7
  45. package/dist/providers/_shared/google/workspace.d.ts +53 -16
  46. package/dist/providers/_shared/google/workspace.js +30 -8
  47. package/dist/providers/_shared/rest/tools.js +2 -2
  48. package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
  49. package/dist/providers/_shared/rest/value-safety.js +699 -7
  50. package/dist/providers/ccb/index.js +1 -1
  51. package/dist/providers/cloudflare/index.js +3 -3
  52. package/dist/providers/cloudflare/rest.js +25 -3
  53. package/dist/providers/cloudflare/skill.generated.js +1 -1
  54. package/dist/providers/cloudflare/value-safety.js +32 -0
  55. package/dist/providers/docs/index.js +3 -3
  56. package/dist/providers/docs/skill.generated.js +2 -2
  57. package/dist/providers/drive/index.js +3 -3
  58. package/dist/providers/drive/skill.generated.js +2 -2
  59. package/dist/providers/forms/index.js +3 -3
  60. package/dist/providers/forms/skill.generated.js +2 -2
  61. package/dist/providers/gmail/index.js +10 -8
  62. package/dist/providers/gmail/skill.generated.js +2 -2
  63. package/dist/providers/mixpanel/index.js +1 -1
  64. package/dist/providers/notion/index.d.ts +7 -0
  65. package/dist/providers/notion/index.js +100 -11
  66. package/dist/providers/notion/rest.js +45 -5
  67. package/dist/providers/notion/skill.generated.js +2 -2
  68. package/dist/providers/notion/value-safety.js +30 -5
  69. package/dist/providers/overflow/index.js +1 -1
  70. package/dist/providers/planning-center/index.js +1 -1
  71. package/dist/providers/sheets/index.d.ts +3 -0
  72. package/dist/providers/sheets/index.js +6 -3
  73. package/dist/providers/sheets/skill.generated.js +2 -2
  74. package/dist/providers/slides/index.js +3 -3
  75. package/dist/providers/slides/skill.generated.js +2 -2
  76. package/dist/providers/stripe/rest.js +9 -2
  77. package/dist/providers/stripe/skill.generated.js +1 -1
  78. package/dist/providers/stripe/value-safety.js +22 -0
  79. package/dist/providers/vercel/rest.js +20 -2
  80. package/dist/providers/vercel/skill.generated.js +1 -1
  81. package/dist/providers/vercel/value-safety.js +22 -0
  82. package/dist/registry.d.ts +2 -3
  83. package/dist/registry.js +8 -9
  84. package/dist/routes/access-tokens.js +82 -75
  85. package/dist/routes/activity-disclosure.js +17 -18
  86. package/dist/routes/activity.js +34 -54
  87. package/dist/routes/actor-labels.js +33 -4
  88. package/dist/routes/agent-path.js +43 -0
  89. package/dist/routes/connect.js +16 -12
  90. package/dist/routes/credentials.js +16 -40
  91. package/dist/routes/mcp.js +145 -95
  92. package/dist/routes/oauth-management.js +11 -5
  93. package/dist/routes/oauth.js +3 -9
  94. package/dist/routes/operator.js +24 -23
  95. package/dist/routes/shared.d.ts +60 -20
  96. package/dist/routes/shared.js +187 -96
  97. package/dist/routes/ui-config.js +437 -97
  98. package/dist/routes/ui-data.js +2 -2
  99. package/dist/routes/ui.js +9 -16
  100. package/dist/runtime/call-admission.js +9 -0
  101. package/dist/server.js +5 -0
  102. package/dist/storage/keys.js +9 -1
  103. package/dist/types.d.ts +11 -0
  104. package/dist/ui.d.ts +1 -1
  105. package/dist/ui.js +16 -14
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/dist/workspace-address.js +9 -0
  109. package/dist/workspace-subject.js +23 -0
  110. package/documentation/README.md +46 -22
  111. package/documentation/architecture.md +355 -50
  112. package/documentation/auth.md +775 -243
  113. package/documentation/code-mode.md +14 -11
  114. package/documentation/deploying.md +121 -24
  115. package/documentation/integrating.md +92 -21
  116. package/documentation/meta-tools.md +14 -9
  117. package/documentation/migration-0.33.md +344 -0
  118. package/documentation/operating.md +3 -1
  119. package/documentation/operator-tests.md +115 -28
  120. package/documentation/operator-ui.md +326 -61
  121. package/documentation/provider-auth-migration.md +37 -23
  122. package/documentation/provider-migration-0.29.md +2 -2
  123. package/examples/worker/AGENTS.md +30 -8
  124. package/examples/worker/README.md +92 -53
  125. package/examples/worker/src/connecta.config.ts +36 -20
  126. package/examples/worker/wrangler.jsonc +3 -0
  127. package/package.json +3 -2
  128. package/templates/node/.env.example +3 -0
  129. package/templates/node/AGENTS.md +27 -6
  130. package/templates/node/Dockerfile +2 -2
  131. package/templates/node/README.md +67 -42
  132. package/templates/node/docker-compose.yml +3 -2
  133. package/templates/node/package.json +4 -3
  134. package/templates/node/src/adopt-token.ts +23 -0
  135. package/templates/node/src/connecta.config.ts +28 -12
  136. package/templates/node/src/rotate-token.ts +17 -0
  137. package/templates/node/src/provision-token.ts +0 -17
@@ -1,10 +1,10 @@
1
1
  import { activityEventVisible } from "./activity-disclosure.js";
2
- import { Duration, Effect } from "effect";
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 { ACTOR_LABEL_BUDGET_MS, ACTOR_LABEL_CONCURRENCY, actorKey, cleanActorLabel, labelLookups, } from "./actor-labels.js";
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.suspend(() => {
19
- const labels = new Map();
20
- const resolve = ({ key, id, provider }) => Effect.tryPromise(() => Promise.resolve(provider.activityActorLabel(id))).pipe(Effect.map(cleanActorLabel), Effect.orElseSucceed(() => undefined), Effect.map((label) => {
21
- if (label)
22
- labels.set(key, label);
23
- }));
24
- return Effect.forEach(labelLookups(page.events.map((event) => event.actor), auth), resolve, {
25
- concurrency: ACTOR_LABEL_CONCURRENCY,
26
- discard: true,
27
- }).pipe(Effect.timeoutOption(Duration.millis(ACTOR_LABEL_BUDGET_MS)), Effect.map(() => ({
28
- ...page,
29
- events: page.events.map((event) => {
30
- const resolved = event.actor.id ? labels.get(actorKey(event.actor)) : undefined;
31
- // Never trust or echo a `label` supplied by storage. The persisted event
32
- // schema has no label; only this authenticated read path may add one.
33
- const actor = {
34
- kind: event.actor.kind,
35
- ...(event.actor.id ? { id: event.actor.id } : {}),
36
- ...(event.actor.namespace ? { namespace: event.actor.namespace } : {}),
37
- };
38
- // Re-check stored client facts, including custom readers and old rows.
39
- const { packageVersion: suppliedPackageVersion, clientName: suppliedName, clientVersion: suppliedVersion, classification: _classification, resultBytes: _resultBytes, kind: _kind, drift: _drift, pool: _pool, actorBasis: _actorBasis, ...record } = event;
40
- const packageVersion = activityPackageVersion(suppliedPackageVersion);
41
- const clientName = activityClientFact(suppliedName, "name");
42
- const clientVersion = activityClientFact(suppliedVersion, "version");
43
- return {
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 admittedPools = new Set();
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
- export const ACTOR_LABEL_CONCURRENCY = 8;
7
- export const ACTOR_LABEL_BUDGET_MS = 1_500;
7
+ const ACTOR_LABEL_CONCURRENCY = 8;
8
+ const ACTOR_LABEL_BUDGET_MS = 1_500;
8
9
  const ACTOR_LABEL_MAX_LENGTH = 160;
9
- export function cleanActorLabel(value) {
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
- export function labelLookups(actors, auth) {
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
+ }
@@ -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, validateAuthPermissions, withSessionCookies, } from "./shared.js";
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, opts.config.identity);
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
- if (authz.principalKey !== handoff.principal || !mayManageConnector(authz, connector)) {
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 (opts.config.ui && context.url.searchParams.get("start") !== "1")
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, visibleRegistry } from "./operator.js";
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* visibleRegistry(context, authz);
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, {