@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.
- package/CHANGELOG.md +57 -0
- package/dist/activity.d.ts +17 -0
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js.map +1 -1
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +101 -0
- package/dist/auth/clerk.js.map +1 -1
- package/dist/auth/downstream-oauth.d.ts +57 -20
- package/dist/auth/downstream-oauth.d.ts.map +1 -1
- package/dist/auth/downstream-oauth.js +275 -67
- package/dist/auth/downstream-oauth.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +165 -103
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +12 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +70 -15
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +238 -19
- package/dist/server.js.map +1 -1
- package/dist/storage/file.d.ts.map +1 -1
- package/dist/storage/file.js +8 -0
- package/dist/storage/file.js.map +1 -1
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +7 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +118 -4
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/activity.ts +20 -0
- package/src/auth/clerk.ts +124 -0
- package/src/auth/downstream-oauth.ts +359 -68
- package/src/connectors/remote-mcp.ts +172 -104
- package/src/index.ts +3 -0
- package/src/registry.ts +79 -19
- package/src/server.ts +306 -16
- package/src/storage/file.ts +7 -0
- package/src/types.ts +23 -0
- package/src/ui.ts +124 -3
- 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
|
-
//
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
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`
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 });
|
package/src/storage/file.ts
CHANGED
|
@@ -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.
|