@zackbart/connecta 0.7.6 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/activity.d.ts +17 -0
  3. package/dist/activity.d.ts.map +1 -1
  4. package/dist/activity.js.map +1 -1
  5. package/dist/auth/clerk.d.ts.map +1 -1
  6. package/dist/auth/clerk.js +101 -0
  7. package/dist/auth/clerk.js.map +1 -1
  8. package/dist/auth/downstream-oauth.d.ts +57 -20
  9. package/dist/auth/downstream-oauth.d.ts.map +1 -1
  10. package/dist/auth/downstream-oauth.js +275 -67
  11. package/dist/auth/downstream-oauth.js.map +1 -1
  12. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  13. package/dist/connectors/remote-mcp.js +165 -103
  14. package/dist/connectors/remote-mcp.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/registry.d.ts +12 -0
  19. package/dist/registry.d.ts.map +1 -1
  20. package/dist/registry.js +70 -15
  21. package/dist/registry.js.map +1 -1
  22. package/dist/server.d.ts.map +1 -1
  23. package/dist/server.js +238 -19
  24. package/dist/server.js.map +1 -1
  25. package/dist/storage/file.d.ts.map +1 -1
  26. package/dist/storage/file.js +8 -0
  27. package/dist/storage/file.js.map +1 -1
  28. package/dist/types.d.ts +21 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/ui.d.ts +7 -1
  31. package/dist/ui.d.ts.map +1 -1
  32. package/dist/ui.js +118 -4
  33. package/dist/ui.js.map +1 -1
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/package.json +1 -1
  37. package/src/activity.ts +20 -0
  38. package/src/auth/clerk.ts +124 -0
  39. package/src/auth/downstream-oauth.ts +359 -68
  40. package/src/connectors/remote-mcp.ts +172 -104
  41. package/src/index.ts +3 -0
  42. package/src/registry.ts +79 -19
  43. package/src/server.ts +306 -16
  44. package/src/storage/file.ts +7 -0
  45. package/src/types.ts +23 -0
  46. package/src/ui.ts +124 -3
  47. package/src/version.ts +1 -1
package/src/server.ts CHANGED
@@ -5,7 +5,9 @@ import { registerMetaTools } from "./meta-tools.js";
5
5
  import { CONNECTA_INSTRUCTIONS } from "./skills.js";
6
6
  import type {
7
7
  ActivityActor,
8
+ ActivityPage,
8
9
  ActivityReadGate,
10
+ ActivityReadPage,
9
11
  ActivityRequestContext,
10
12
  ActivityStore,
11
13
  } from "./activity.js";
@@ -26,6 +28,7 @@ import type {
26
28
  ConnectorContext,
27
29
  ConnectorCredentialConfig,
28
30
  ConnectorCredentialValues,
31
+ ConnectorStatus,
29
32
  ConnectaBranding,
30
33
  Executor,
31
34
  InboundAuth,
@@ -43,10 +46,16 @@ import {
43
46
  buildUiData,
44
47
  CONNECTA_FAVICON_SVG,
45
48
  credentialManagementCapability,
49
+ isSafeHttpUrl,
46
50
  operatorPageForPath,
47
51
  resolveBranding,
48
52
  renderUiHtml,
49
53
  } from "./ui.js";
54
+ import { oauthValueStorageKey } from "./auth/downstream-oauth.js";
55
+ import {
56
+ closeConnectorScope,
57
+ type DeferredWork,
58
+ } from "./connector-scope.js";
50
59
 
51
60
  const CORS_HEADERS = {
52
61
  "Access-Control-Allow-Origin": "*",
@@ -263,11 +272,15 @@ async function authorize(
263
272
  );
264
273
  return { ok: false, response: unusableBinding() };
265
274
  }
275
+ const actorNamespace = activityActorNamespace(provider);
266
276
  return {
267
277
  ok: true,
268
278
  actor: {
269
279
  kind: provider.kind,
270
280
  ...(subjectId ? { id: subjectId } : {}),
281
+ ...(subjectId && actorNamespace
282
+ ? { namespace: actorNamespace }
283
+ : {}),
271
284
  },
272
285
  ...(result.userId && provider.uiAuth?.kind === "clerk"
273
286
  ? { uiAdminEligible: true }
@@ -446,10 +459,11 @@ async function authorizeUiAdmin(
446
459
  baseUrl: string,
447
460
  auth: InboundAuth[],
448
461
  logger: Logger,
462
+ purpose = "credential management",
449
463
  ): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> {
450
- // Credential mutation is intentionally narrower than /mcp and /ui/data: only
464
+ // Operator mutation is intentionally narrower than /mcp and /ui/data: only
451
465
  // an interactive Clerk provider may admit it. A static bearer token is useful
452
- // for headless tool calls but must not become a vault-admin key.
466
+ // for headless tool calls but must not become a deployment-admin key.
453
467
  //
454
468
  // EVERY Clerk provider gets a turn, the way the /mcp gate does, because the
455
469
  // documented per-team pattern is several `clerkAuth(...)`s that differ only in
@@ -465,7 +479,7 @@ async function authorizeUiAdmin(
465
479
  return {
466
480
  ok: false,
467
481
  response: privateJson(
468
- { error: "credential management requires Clerk authentication" },
482
+ { error: `${purpose} requires Clerk authentication` },
469
483
  { status: 403 },
470
484
  ),
471
485
  };
@@ -490,7 +504,7 @@ async function authorizeUiAdmin(
490
504
  );
491
505
  if (!binding.ok) {
492
506
  logger.warn(
493
- `[connecta] refused a credential-API request admitted by inbound auth ` +
507
+ `[connecta] refused an operator-mutation request admitted by inbound auth ` +
494
508
  `provider "${provider.kind}" with 403: ${binding.reason}.`,
495
509
  );
496
510
  lastResponse = unusableBinding();
@@ -732,7 +746,7 @@ async function handleCredentialRequest(
732
746
  input.input.values,
733
747
  admin.userId,
734
748
  );
735
- opts.registry.invalidate(connectorId);
749
+ await opts.registry.invalidateStored(connectorId);
736
750
  // The credential the last verdict judged is gone; judging its replacement
737
751
  // is the next check's job, not this one's.
738
752
  await opts.registry.clearCredentialHealth(connectorId);
@@ -744,7 +758,7 @@ async function handleCredentialRequest(
744
758
 
745
759
  if (request.method === "DELETE") {
746
760
  await opts.credentialVault.delete(connectorId);
747
- opts.registry.invalidate(connectorId);
761
+ await opts.registry.invalidateStored(connectorId);
748
762
  await opts.registry.clearCredentialHealth(connectorId);
749
763
  return new Response(null, {
750
764
  status: 204,
@@ -758,6 +772,105 @@ async function handleCredentialRequest(
758
772
  return privateJson({ error: "method not allowed" }, { status: 405 });
759
773
  }
760
774
 
775
+ async function handleOAuthManagementRequest(
776
+ request: Request,
777
+ connectorId: string,
778
+ opts: ServerOptions,
779
+ baseUrl: string,
780
+ defer?: DeferredWork,
781
+ ): Promise<Response> {
782
+ if (!isSameOrigin(request, baseUrl)) {
783
+ return privateJson(
784
+ { error: "same-origin request required" },
785
+ { status: 403 },
786
+ );
787
+ }
788
+ const admin = await authorizeUiAdmin(
789
+ request,
790
+ baseUrl,
791
+ opts.auth,
792
+ opts.logger,
793
+ "OAuth management",
794
+ );
795
+ if (!admin.ok) return admin.response;
796
+
797
+ const connector = opts.registry.getConnector(connectorId);
798
+ if (!connector?.disconnectAuth || !connector.startAuth) {
799
+ return privateJson(
800
+ { error: "unknown OAuth connector" },
801
+ { status: 404 },
802
+ );
803
+ }
804
+ if (request.method !== "DELETE" && request.method !== "POST") {
805
+ return privateJson({ error: "method not allowed" }, { status: 405 });
806
+ }
807
+
808
+ const requestScope = {};
809
+ const ctx = opts.registry.contextFor(connectorId, baseUrl, requestScope);
810
+ try {
811
+ let result: ConnectorStatus | undefined;
812
+ let operationError: unknown;
813
+ try {
814
+ if (request.method === "DELETE") {
815
+ await connector.disconnectAuth(ctx);
816
+ } else {
817
+ result = await connector.startAuth(ctx, { force: true });
818
+ }
819
+ } catch (error) {
820
+ operationError = error;
821
+ }
822
+
823
+ // The old grant and its catalog verdict are invalid after either operation,
824
+ // including a partially failed physical cleanup whose epoch fence succeeded.
825
+ try {
826
+ await opts.registry.invalidateStored(connectorId);
827
+ await opts.registry.clearCredentialHealth(connectorId);
828
+ } catch (error) {
829
+ operationError ??= error;
830
+ }
831
+ if (operationError) {
832
+ return privateJson({ error: msg(operationError) }, { status: 400 });
833
+ }
834
+
835
+ if (request.method === "DELETE") {
836
+ return new Response(null, {
837
+ status: 204,
838
+ headers: {
839
+ "Cache-Control": "no-store",
840
+ "Referrer-Policy": "no-referrer",
841
+ },
842
+ });
843
+ }
844
+
845
+ const authorizationUrl = isSafeHttpUrl(result!.authorizationUrl)
846
+ ? result!.authorizationUrl
847
+ : undefined;
848
+ if (result!.state === "error") {
849
+ return privateJson(
850
+ { error: result!.message || "OAuth authorization could not start" },
851
+ { status: 502 },
852
+ );
853
+ }
854
+ if (result!.state === "auth_required" && !authorizationUrl) {
855
+ return privateJson(
856
+ {
857
+ error:
858
+ result!.message ||
859
+ "OAuth authorization requires consent but no safe URL is available",
860
+ },
861
+ { status: 502 },
862
+ );
863
+ }
864
+ return privateJson({
865
+ state: result!.state,
866
+ ...(result!.message ? { message: result!.message } : {}),
867
+ ...(authorizationUrl ? { authorizationUrl } : {}),
868
+ });
869
+ } finally {
870
+ await closeConnectorScope(connector, ctx, defer);
871
+ }
872
+ }
873
+
761
874
  /** Length beyond which a rejected toolkit name is not echoed back. */
762
875
  const MAX_ECHOED_TOOLKIT_NAME = 64;
763
876
 
@@ -823,6 +936,165 @@ function identityLabel(actor: ActivityActor): string {
823
936
  return actor.id ? `${actor.kind} ${loggableValue(actor.id)}` : actor.kind;
824
937
  }
825
938
 
939
+ const ACTIVITY_ACTOR_NAMESPACE_RE = /^[\x21-\x7e]{1,256}$/;
940
+ const ACTIVITY_LABEL_CONCURRENCY = 8;
941
+ const ACTIVITY_LABEL_PAGE_BUDGET_MS = 1_500;
942
+ const ACTIVITY_LABEL_MAX_LENGTH = 160;
943
+
944
+ function activityActorNamespace(
945
+ provider: InboundAuth,
946
+ ): string | undefined {
947
+ return typeof provider.activityActorNamespace === "string" &&
948
+ ACTIVITY_ACTOR_NAMESPACE_RE.test(provider.activityActorNamespace)
949
+ ? provider.activityActorNamespace
950
+ : undefined;
951
+ }
952
+
953
+ function cleanActivityActorLabel(value: unknown): string | undefined {
954
+ if (typeof value !== "string") return undefined;
955
+ const compact = value.replace(/\s+/gu, " ").trim();
956
+ if (!compact) return undefined;
957
+ return Array.from(compact).slice(0, ACTIVITY_LABEL_MAX_LENGTH).join("");
958
+ }
959
+
960
+ async function boundedActivityActorLabel(
961
+ hook: NonNullable<InboundAuth["activityActorLabel"]>,
962
+ id: string,
963
+ budgetMs: number,
964
+ ): Promise<string | undefined> {
965
+ let timer: number | undefined;
966
+ try {
967
+ return await Promise.race([
968
+ Promise.resolve(hook(id))
969
+ .then(cleanActivityActorLabel)
970
+ .catch(() => undefined),
971
+ new Promise<undefined>((resolve) => {
972
+ timer = setTimeout(resolve, budgetMs);
973
+ }),
974
+ ]);
975
+ } catch {
976
+ return undefined;
977
+ } finally {
978
+ if (timer !== undefined) clearTimeout(timer);
979
+ }
980
+ }
981
+
982
+ /**
983
+ * Add display-only actor labels to one authorized activity page. Resolution is
984
+ * best-effort, bounded, and read-time only: stored events retain stable ids and
985
+ * a profile-provider outage falls back to those ids without failing the page.
986
+ */
987
+ async function enrichActivityActorLabels(
988
+ page: ActivityPage,
989
+ auth: readonly InboundAuth[],
990
+ ): Promise<ActivityReadPage> {
991
+ const identities = new Map<
992
+ string,
993
+ { kind: string; id: string; namespace?: string }
994
+ >();
995
+ for (const event of page.events) {
996
+ if (!event.actor.id) continue;
997
+ identities.set(JSON.stringify([
998
+ event.actor.kind,
999
+ event.actor.namespace,
1000
+ event.actor.id,
1001
+ ]), {
1002
+ kind: event.actor.kind,
1003
+ id: event.actor.id,
1004
+ ...(event.actor.namespace
1005
+ ? { namespace: event.actor.namespace }
1006
+ : {}),
1007
+ });
1008
+ }
1009
+ const queue = [...identities.entries()];
1010
+ const labels = new Map<string, string>();
1011
+ let next = 0;
1012
+ const deadline = Date.now() + ACTIVITY_LABEL_PAGE_BUDGET_MS;
1013
+ const workers = Array.from(
1014
+ { length: Math.min(ACTIVITY_LABEL_CONCURRENCY, queue.length) },
1015
+ async () => {
1016
+ while (next < queue.length) {
1017
+ const [key, identity] = queue[next++];
1018
+ const sameKindProviders = auth
1019
+ .map((provider, index) => ({ provider, index }))
1020
+ .filter(({ provider }) => provider.kind === identity.kind);
1021
+ const candidates = sameKindProviders.filter(({ provider }) =>
1022
+ Boolean(provider.activityActorLabel),
1023
+ );
1024
+ const eligible = identity.namespace
1025
+ ? candidates.filter(
1026
+ ({ provider }) =>
1027
+ activityActorNamespace(provider) === identity.namespace,
1028
+ )
1029
+ : (() => {
1030
+ const directoryKey = ({
1031
+ provider,
1032
+ index,
1033
+ }: (typeof sameKindProviders)[number]) => {
1034
+ const namespace = activityActorNamespace(provider);
1035
+ return namespace === undefined
1036
+ ? `provider:${index}`
1037
+ : `namespace:${namespace}`;
1038
+ };
1039
+ // Every same-kind provider participates in the ambiguity check,
1040
+ // even if it cannot resolve labels. Otherwise a legacy ID owned
1041
+ // by a provider without a resolver could be disclosed to a
1042
+ // different provider that happens to have one.
1043
+ const directories = new Set(
1044
+ sameKindProviders.map(directoryKey),
1045
+ );
1046
+ if (directories.size !== 1) return [];
1047
+ const [directory] = directories;
1048
+ return candidates.filter(
1049
+ (candidate) => directoryKey(candidate) === directory,
1050
+ );
1051
+ })();
1052
+ // One namespace is one directory. Use its first configured resolver so
1053
+ // duplicate gate adapters over the same Clerk instance do not multiply
1054
+ // the provider-level concurrency cap.
1055
+ const provider = eligible[0]?.provider;
1056
+ if (!provider) continue;
1057
+ const remaining = deadline - Date.now();
1058
+ if (remaining <= 0) return;
1059
+ const label = await boundedActivityActorLabel(
1060
+ provider.activityActorLabel!.bind(provider),
1061
+ identity.id,
1062
+ remaining,
1063
+ );
1064
+ if (label) {
1065
+ labels.set(key, label);
1066
+ }
1067
+ }
1068
+ },
1069
+ );
1070
+ await Promise.all(workers);
1071
+ return {
1072
+ ...page,
1073
+ events: page.events.map((event) => {
1074
+ const resolved = event.actor.id
1075
+ ? labels.get(JSON.stringify([
1076
+ event.actor.kind,
1077
+ event.actor.namespace,
1078
+ event.actor.id,
1079
+ ]))
1080
+ : undefined;
1081
+ // Never trust or echo a `label` supplied by storage. The persisted event
1082
+ // schema has no label; only this authenticated read path may add one.
1083
+ const actor: ActivityActor = {
1084
+ kind: event.actor.kind,
1085
+ ...(event.actor.id ? { id: event.actor.id } : {}),
1086
+ ...(event.actor.namespace
1087
+ ? { namespace: event.actor.namespace }
1088
+ : {}),
1089
+ };
1090
+ return {
1091
+ ...event,
1092
+ actor: resolved ? { ...actor, label: resolved } : actor,
1093
+ };
1094
+ }),
1095
+ };
1096
+ }
1097
+
826
1098
  /**
827
1099
  * Resolve `?toolkit=<name>` into the registry view this connection may see,
828
1100
  * enforcing the caller's toolkit binding (docs/toolkits.md) on the way.
@@ -1026,17 +1298,16 @@ async function serveMcp(
1026
1298
  * paths that would otherwise pay nothing.
1027
1299
  *
1028
1300
  * Identical bodies do not hide a connector id if the clock still sorts them.
1029
- * `KvOAuthProvider.verifyState` reads `oauth:state` before it can fail, so a
1030
- * configured id costs one storage round trip on the Workers deployment shape
1031
- * that is a real KV read, tens of milliseconds cold while an id that names
1032
- * nothing used to return having touched no I/O at all. That gap is an oracle:
1033
- * sample the two and a wordlist recovers the connector list the flat 400 was
1034
- * meant to withhold. So the zero-I/O refusals read the same key in the same
1035
- * `conn:<id>:` namespace, which for an unconfigured id is simply a miss.
1301
+ * `KvOAuthProvider.verifyState` reads `oauth:state` and its generation before
1302
+ * it can reject a mismatched value, so a configured id costs two storage round
1303
+ * trips on the ordinary path while an id naming nothing used to touch no I/O.
1304
+ * That gap is an oracle: sample the two and a wordlist recovers the connector
1305
+ * list the flat 400 was meant to withhold. So zero-I/O refusals read the same
1306
+ * keys in the same `conn:<id>:` namespace, where an unconfigured id gets misses.
1036
1307
  *
1037
1308
  * This is deliberately *not* a constant-time claim, and docs/connectors.md says
1038
1309
  * so in prose: a hit and a miss are not identical in a KV store, and a connector
1039
- * shipping its own `verifyState` may do more or less work than one read. What it
1310
+ * shipping its own `verifyState` may do more or less work. What it
1040
1311
  * removes is the order-of-magnitude "no I/O versus a round trip" difference,
1041
1312
  * which is the only part of the signal that makes enumeration cheap.
1042
1313
  *
@@ -1046,7 +1317,10 @@ async function serveMcp(
1046
1317
  */
1047
1318
  async function equalizeRefusalCost(context: ConnectorContext): Promise<void> {
1048
1319
  try {
1049
- await context.storage.get("oauth:state");
1320
+ const generation = await context.storage.get("oauth:generation");
1321
+ await context.storage.get(
1322
+ oauthValueStorageKey("oauth:state", generation),
1323
+ );
1050
1324
  } catch {
1051
1325
  // Deliberately ignored — see above.
1052
1326
  }
@@ -1253,6 +1527,20 @@ export function createFetchHandler(
1253
1527
  baseUrl,
1254
1528
  );
1255
1529
  }
1530
+ const oauthManagementMatch =
1531
+ /^\/ui\/oauth\/([a-z0-9_-]+)$/.exec(path);
1532
+ if (oauthManagementMatch) {
1533
+ if (request.method === "OPTIONS") {
1534
+ return privateJson({ error: "method not allowed" }, { status: 405 });
1535
+ }
1536
+ return handleOAuthManagementRequest(
1537
+ request,
1538
+ oauthManagementMatch[1],
1539
+ opts,
1540
+ baseUrl,
1541
+ defer,
1542
+ );
1543
+ }
1256
1544
 
1257
1545
  if (request.method === "OPTIONS") {
1258
1546
  for (const a of auth) {
@@ -1404,6 +1692,7 @@ export function createFetchHandler(
1404
1692
  credentialManagement,
1405
1693
  opts.toolkits,
1406
1694
  defer,
1695
+ eligibleClerkOperator,
1407
1696
  );
1408
1697
  return privateJson(data);
1409
1698
  }
@@ -1438,7 +1727,8 @@ export function createFetchHandler(
1438
1727
  ? Math.min(100, Math.max(1, Math.trunc(requestedLimit)))
1439
1728
  : 50;
1440
1729
  try {
1441
- return privateJson(await opts.activity.list({ cursor, limit }));
1730
+ const page = await opts.activity.list({ cursor, limit });
1731
+ return privateJson(await enrichActivityActorLabels(page, auth));
1442
1732
  } catch (error) {
1443
1733
  if (error instanceof InvalidActivityCursorError) {
1444
1734
  return privateJson({ error: error.message }, { status: 400 });
@@ -72,6 +72,13 @@ export function fileStorage(
72
72
  }
73
73
  }
74
74
  const persist = () => {
75
+ // Physical expiry rides on an operation that was already going to write.
76
+ // A read must not flush this instance's load-once snapshot: another live
77
+ // instance may have written newer unrelated values since we loaded it.
78
+ const now = Date.now();
79
+ for (const [key, entry] of Object.entries(data)) {
80
+ if (entry.exp && now > entry.exp) delete data[key];
81
+ }
75
82
  const dir = dirname(path);
76
83
  if (dir) mkdirSync(dir, { recursive: true, mode: 0o700 });
77
84
  const tmp = `${path}.tmp`;
package/src/types.ts CHANGED
@@ -214,6 +214,12 @@ export interface Connector {
214
214
  ctx: ConnectorContext,
215
215
  opts?: { force?: boolean },
216
216
  ): Promise<ConnectorStatus>;
217
+ /**
218
+ * Optional: remove every stored downstream OAuth credential and pending flow
219
+ * without immediately starting a replacement flow. Present only on
220
+ * connectors whose authorization can be managed by the operator UI.
221
+ */
222
+ disconnectAuth?(ctx: ConnectorContext): Promise<void>;
217
223
  /**
218
224
  * Verify the OAuth `state` returned to /oauth/callback/<id> against the value
219
225
  * this connector generated when it started the flow. Required whenever
@@ -431,6 +437,23 @@ export interface ConnectaBranding {
431
437
  /** An inbound authentication provider (bearer token, Clerk, ...). */
432
438
  export interface InboundAuth {
433
439
  kind: string;
440
+ /**
441
+ * Stable, non-secret namespace of the identity directory behind
442
+ * `activityActorLabel`. Stored with new activity actors so two providers with
443
+ * the same `kind` never receive each other's ids. Legacy actors without a
444
+ * namespace are resolved only when exactly one directory is unambiguous.
445
+ * Must be 1–256 printable, non-space ASCII characters; invalid values are
446
+ * treated as an unknown directory and are not persisted.
447
+ */
448
+ activityActorNamespace?: string;
449
+ /**
450
+ * Best-effort friendly label for a stable activity actor id. Called only
451
+ * while serving an authorized activity read, never during tool admission or
452
+ * event writes. The result is display-only and cannot grant access.
453
+ */
454
+ activityActorLabel?(
455
+ subjectId: string,
456
+ ): string | undefined | Promise<string | undefined>;
434
457
  /**
435
458
  * Optional browser sign-in configuration. When present, operator pages use it
436
459
  * provider instead of asking the operator to paste a static bearer secret.