@fungi.computer/veil 0.0.0-stage → 0.1.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -2
  3. package/dist/best-effort-cleanup.d.ts +2 -0
  4. package/dist/best-effort-cleanup.js +5 -0
  5. package/dist/event-verifiers.d.ts +80 -0
  6. package/dist/event-verifiers.js +168 -0
  7. package/dist/gatekeeper.d.ts +202 -0
  8. package/dist/gatekeeper.js +231 -0
  9. package/dist/index.d.ts +254 -0
  10. package/dist/index.js +267 -0
  11. package/dist/model-credentials.d.ts +226 -0
  12. package/dist/model-credentials.js +133 -0
  13. package/dist/model-key-grants.d.ts +56 -0
  14. package/dist/model-key-grants.js +107 -0
  15. package/dist/model-key-network.d.ts +33 -0
  16. package/dist/model-key-network.js +202 -0
  17. package/dist/model-keys.d.ts +42 -0
  18. package/dist/model-keys.js +115 -0
  19. package/dist/nango-custody-http.d.ts +16 -0
  20. package/dist/nango-custody-http.js +93 -0
  21. package/dist/nango-git.d.ts +19 -0
  22. package/dist/nango-git.js +125 -0
  23. package/dist/nango-model-keys.d.ts +13 -0
  24. package/dist/nango-model-keys.js +48 -0
  25. package/dist/nango-world-seat.d.ts +14 -0
  26. package/dist/nango-world-seat.js +47 -0
  27. package/dist/nango.d.ts +16 -0
  28. package/dist/nango.js +300 -0
  29. package/dist/openapi-contract.d.ts +64 -0
  30. package/dist/openapi-contract.js +11 -0
  31. package/dist/openapi-effect.d.ts +9 -0
  32. package/dist/openapi-effect.js +284 -0
  33. package/dist/openapi-http.d.ts +2 -0
  34. package/dist/openapi-http.js +37 -0
  35. package/dist/openapi-secrets.d.ts +6 -0
  36. package/dist/openapi-secrets.js +80 -0
  37. package/dist/openapi.d.ts +5 -0
  38. package/dist/openapi.js +24 -0
  39. package/dist/owned-infrastructure.d.ts +13 -0
  40. package/dist/owned-infrastructure.js +17 -0
  41. package/dist/provider-egress.d.ts +51 -0
  42. package/dist/provider-egress.js +155 -0
  43. package/dist/public-egress-policy.d.ts +13 -0
  44. package/dist/public-egress-policy.js +101 -0
  45. package/dist/public-fetch.d.ts +90 -0
  46. package/dist/public-fetch.js +347 -0
  47. package/dist/webhook-trigger-secrets.d.ts +23 -0
  48. package/dist/webhook-trigger-secrets.js +53 -0
  49. package/dist/world-seat.d.ts +40 -0
  50. package/dist/world-seat.js +82 -0
  51. package/package.json +140 -3
package/dist/index.js ADDED
@@ -0,0 +1,267 @@
1
+ import * as Cause from "effect/Cause";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Exit from "effect/Exit";
4
+ import * as Option from "effect/Option";
5
+ import * as Schema from "effect/Schema";
6
+ const text = Schema.NonEmptyString;
7
+ const integer = Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0));
8
+ /** The repository identity selected by the Team, never by model input. */
9
+ export const RepositoryScopeSchema = Schema.Struct({
10
+ id: integer.check(Schema.isGreaterThan(0)),
11
+ owner: text,
12
+ name: text,
13
+ });
14
+ /** Bounded, credential-free metadata returned by the GitHub operation. */
15
+ export const GitHubRepositorySchema = Schema.Struct({
16
+ id: integer.check(Schema.isGreaterThan(0)),
17
+ name: text,
18
+ fullName: text,
19
+ private: Schema.Boolean,
20
+ defaultBranch: text,
21
+ });
22
+ const providerRepositorySchema = Schema.Struct({
23
+ id: GitHubRepositorySchema.fields.id,
24
+ name: text,
25
+ full_name: GitHubRepositorySchema.fields.fullName,
26
+ private: Schema.Boolean,
27
+ default_branch: text,
28
+ });
29
+ export const ConnectInputSchema = Schema.Struct({
30
+ requestId: text,
31
+ actorUserId: text,
32
+ holderAgentId: text,
33
+ repository: RepositoryScopeSchema,
34
+ });
35
+ export const AttemptSchema = Schema.Struct({
36
+ ...ConnectInputSchema.fields,
37
+ teamId: text,
38
+ attemptId: text,
39
+ connectionId: text,
40
+ integrationId: text,
41
+ expiresAt: integer,
42
+ });
43
+ /** Produced only by a custodian's verified completion boundary. */
44
+ export const CompletionSchema = Schema.Struct({
45
+ teamId: text,
46
+ attemptId: text,
47
+ principalId: text,
48
+ integrationId: text,
49
+ locator: text,
50
+ });
51
+ export const ConnectionReceiptSchema = Schema.Struct({
52
+ requestId: text,
53
+ connectionId: text,
54
+ holderAgentId: text,
55
+ repository: RepositoryScopeSchema,
56
+ authorityVersion: integer,
57
+ status: Schema.Literals(["active", "disconnected"]),
58
+ cleanup: Schema.Literals(["none", "pending"]),
59
+ });
60
+ export const ReadBindingSchema = Schema.Struct({
61
+ teamId: text,
62
+ holderAgentId: text,
63
+ connectionId: text,
64
+ repositoryId: integer,
65
+ });
66
+ export const DisconnectInputSchema = Schema.Struct({
67
+ requestId: text,
68
+ actorUserId: text,
69
+ connectionId: text,
70
+ });
71
+ const completionLookupSchema = Schema.Union([
72
+ Schema.Struct({ status: Schema.Literal("unresolved") }),
73
+ Schema.Struct({ status: Schema.Literal("ambiguous") }),
74
+ Schema.Struct({
75
+ status: Schema.Literal("verified"),
76
+ completion: CompletionSchema,
77
+ }),
78
+ ]);
79
+ const connectSessionSchema = Schema.Struct({
80
+ token: text,
81
+ connectUrl: Schema.String,
82
+ expiresAt: integer,
83
+ });
84
+ /** Sanitized public failure; an optional cause is retained internally only. */
85
+ export class VeilError extends Error {
86
+ code;
87
+ constructor(code, cause) {
88
+ super(code);
89
+ this.code = code;
90
+ this.name = "VeilError";
91
+ if (cause !== undefined)
92
+ Object.defineProperty(this, "cause", { value: cause, enumerable: false });
93
+ }
94
+ }
95
+ const orphanCodes = new Set([
96
+ "expired",
97
+ "denied",
98
+ "conflict",
99
+ "reconnect_required",
100
+ ]);
101
+ function failure(cause) {
102
+ return new VeilError(cause instanceof VeilError ? cause.code : "unavailable");
103
+ }
104
+ function owned(operation) {
105
+ return Effect.try({ try: operation, catch: failure });
106
+ }
107
+ function run(program, signal) {
108
+ return Effect.runPromiseExit(program, signal === undefined ? undefined : { signal }).then((exit) => {
109
+ if (Exit.isSuccess(exit))
110
+ return exit.value;
111
+ const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause));
112
+ throw (error ??
113
+ new VeilError(Cause.hasInterrupts(exit.cause) ? "cancelled" : "unavailable"));
114
+ });
115
+ }
116
+ /** One Effect policy behind the ordinary connect and GitHub Gatekeeper calls. */
117
+ export function createGitHubConnections(options) {
118
+ const { owner, custodian } = options;
119
+ const repository = (access) => Effect.gen(function* () {
120
+ const raw = yield* Effect.tryPromise({
121
+ try: (signal) => custodian.readRepository(access, signal),
122
+ catch: failure,
123
+ });
124
+ const result = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(providerRepositorySchema))(raw).pipe(Effect.mapError(() => new VeilError("invalid_response")));
125
+ if (result.id !== access.repository.id)
126
+ return yield* Effect.fail(new VeilError("denied"));
127
+ return {
128
+ id: result.id,
129
+ name: result.name,
130
+ fullName: result.full_name,
131
+ private: result.private,
132
+ defaultBranch: result.default_branch,
133
+ };
134
+ });
135
+ /** Delete a verified connection the Team will never hold, then refuse it. */
136
+ const release = (completion, code) => Effect.gen(function* () {
137
+ const raw = yield* Effect.tryPromise({
138
+ try: (signal) => custodian.deleteConnection(completion.locator, signal),
139
+ catch: failure,
140
+ });
141
+ const custody = yield* Schema.decodeUnknownEffect(Schema.Literals(["deleted", "absent"]))(raw).pipe(Effect.mapError(() => new VeilError("invalid_response")));
142
+ yield* owned(() => {
143
+ owner.recordOrphan(completion, code, custody);
144
+ });
145
+ return yield* Effect.fail(new VeilError(code));
146
+ });
147
+ const activate = (verified) => Effect.gen(function* () {
148
+ const completion = yield* Schema.decodeUnknownEffect(CompletionSchema)(verified).pipe(Effect.mapError(() => new VeilError("invalid_completion")));
149
+ const prepared = yield* owned(() => owner.prepareCompletion(completion));
150
+ if (prepared.status === "completed")
151
+ return prepared.receipt;
152
+ if (prepared.status === "orphaned")
153
+ return yield* release(completion, prepared.code);
154
+ const { attempt } = prepared;
155
+ return yield* Effect.gen(function* () {
156
+ const metadata = yield* repository({
157
+ locator: completion.locator,
158
+ repository: attempt.repository,
159
+ });
160
+ return yield* Effect.tryPromise({
161
+ try: async () => owner.activate(completion, metadata),
162
+ catch: failure,
163
+ });
164
+ }).pipe(Effect.catchIf((error) => orphanCodes.has(error.code), (error) => Effect.gen(function* () {
165
+ // A concurrent delivery of this same completion may have won.
166
+ const settled = yield* owned(() => owner.prepareCompletion(completion));
167
+ if (settled.status === "completed")
168
+ return settled.receipt;
169
+ return yield* release(completion, error.code);
170
+ })));
171
+ });
172
+ return {
173
+ connect: (input, signal) => run(Effect.gen(function* () {
174
+ const command = yield* Schema.decodeUnknownEffect(ConnectInputSchema)(input).pipe(Effect.mapError(() => new VeilError("invalid_input")));
175
+ const prepared = yield* owned(() => owner.begin(command));
176
+ if (prepared.status === "completed")
177
+ return prepared;
178
+ const { attempt } = prepared;
179
+ const rawSession = yield* Effect.tryPromise({
180
+ try: (control) => custodian.openSession({
181
+ teamId: attempt.teamId,
182
+ attemptId: attempt.attemptId,
183
+ principalId: attempt.actorUserId,
184
+ integrationId: attempt.integrationId,
185
+ }, control),
186
+ catch: failure,
187
+ });
188
+ const session = yield* Schema.decodeUnknownEffect(connectSessionSchema)(rawSession).pipe(Effect.mapError(() => new VeilError("invalid_response")));
189
+ return {
190
+ status: "pending",
191
+ attemptId: attempt.attemptId,
192
+ ...session,
193
+ };
194
+ }), signal),
195
+ /** The one entry for signed custodian webhooks: activation or reconnect evidence. */
196
+ complete: (rawBody, signature, signal) => run(Effect.gen(function* () {
197
+ const event = yield* Effect.tryPromise({
198
+ try: () => custodian.verifyWebhook(rawBody, signature),
199
+ catch: failure,
200
+ });
201
+ if (event.operation === "creation")
202
+ return yield* activate(event.completion);
203
+ const completion = yield* Schema.decodeUnknownEffect(CompletionSchema)(event.completion).pipe(Effect.mapError(() => new VeilError("invalid_completion")));
204
+ const prepared = yield* owned(() => owner.prepareCompletion(completion));
205
+ if (prepared.status !== "completed")
206
+ return { status: "not_held" };
207
+ return yield* owned(() => owner.requireReconnect(completion.locator, "refresh_failed"));
208
+ }), signal),
209
+ reconcile: (input, signal) => run(Effect.gen(function* () {
210
+ const command = yield* Schema.decodeUnknownEffect(ConnectInputSchema)(input).pipe(Effect.mapError(() => new VeilError("invalid_input")));
211
+ const prepared = yield* owned(() => owner.readAttempt(command));
212
+ if (prepared.status === "completed")
213
+ return prepared;
214
+ const raw = yield* Effect.tryPromise({
215
+ try: (control) => custodian.findCompletion(prepared.attempt, control),
216
+ catch: failure,
217
+ });
218
+ const result = yield* Schema.decodeUnknownEffect(completionLookupSchema)(raw).pipe(Effect.mapError(() => new VeilError("invalid_response")));
219
+ if (result.status === "ambiguous")
220
+ return yield* Effect.fail(new VeilError("ambiguous"));
221
+ if (result.status === "unresolved")
222
+ return {
223
+ status: "pending",
224
+ attemptId: prepared.attempt.attemptId,
225
+ };
226
+ if (result.completion.attemptId !== prepared.attempt.attemptId)
227
+ return yield* Effect.fail(new VeilError("invalid_completion"));
228
+ const receipt = yield* activate(result.completion);
229
+ return { status: "completed", receipt };
230
+ }), signal),
231
+ /** One bounded host recovery call; durable pending work survives interrupted or uncertain deletion. */
232
+ cleanupOne: (signal) => run(Effect.gen(function* () {
233
+ const work = yield* owned(() => owner.nextCleanup());
234
+ if (work === undefined)
235
+ return { status: "idle" };
236
+ const raw = yield* Effect.tryPromise({
237
+ try: (control) => custodian.deleteConnection(work.locator, control),
238
+ catch: failure,
239
+ });
240
+ const custody = yield* Schema.decodeUnknownEffect(Schema.Literals(["deleted", "absent"]))(raw).pipe(Effect.mapError(() => new VeilError("invalid_response")));
241
+ yield* owned(() => {
242
+ owner.acknowledgeCleanup(work);
243
+ });
244
+ return {
245
+ status: "completed",
246
+ connectionId: work.connectionId,
247
+ authorityVersion: work.authorityVersion,
248
+ custody,
249
+ };
250
+ }), signal),
251
+ readRepository: (binding, signal) => run(Effect.gen(function* () {
252
+ const command = yield* Schema.decodeUnknownEffect(ReadBindingSchema)(binding).pipe(Effect.mapError(() => new VeilError("invalid_input")));
253
+ // Admission is the fresh owner decision immediately before this one egress.
254
+ const access = yield* owned(() => owner.authorize(command));
255
+ return yield* repository(access).pipe(Effect.tapError((error) => error.code === "reconnect_required"
256
+ ? owned(() => owner.requireReconnect(access.locator, "provider_unauthorized")).pipe(Effect.asVoid)
257
+ : Effect.void));
258
+ }), signal),
259
+ disconnect: (input) => run(Effect.gen(function* () {
260
+ const command = yield* Schema.decodeUnknownEffect(DisconnectInputSchema)(input).pipe(Effect.mapError(() => new VeilError("invalid_input")));
261
+ return yield* Effect.tryPromise({
262
+ try: async () => owner.disconnect(command),
263
+ catch: failure,
264
+ });
265
+ })),
266
+ };
267
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Which model credentials Fungi may use, and where. This is the single owner of
3
+ * provider login policy: the local runtime, the TUI login registry and the Hub
4
+ * read it; none of them decides visibility on its own.
5
+ *
6
+ * Stance: like Pi and OpenCode, offer a person's own subscription locally and
7
+ * hosted, and turn a provider off through `subscriptionKillSwitch` if it
8
+ * objects or blocks tokens. A subscription is always one person's: never
9
+ * Team-scoped, shared or pooled. The new OpenAI ChatGPT plan flow is
10
+ * local-only; hosted use awaits OpenAI approval. The retired Codex provider
11
+ * is forbidden on both hosts.
12
+ * See wiki/3-resources/decisions/adr-byok-model-keys-v1.md.
13
+ */
14
+ /** The owner's own machine, or Fungi's servers acting for a Team. */
15
+ export type CredentialHost = "local" | "hosted";
16
+ /** A provider API key, or the provider's consumer subscription sign-in. */
17
+ export type CredentialMethod = "api_key" | "subscription";
18
+ export type CredentialPermission = Readonly<{
19
+ status: "allowed";
20
+ }> | Readonly<{
21
+ status: "forbidden";
22
+ reason: string;
23
+ source: string;
24
+ }>
25
+ /** Not reviewed against the provider's terms: local status quo, never hosted. */
26
+ | Readonly<{
27
+ status: "unreviewed";
28
+ }>;
29
+ /**
30
+ * Reviewed providers, keyed by Pi provider id. `openai-compatible` is a
31
+ * Team-configured OpenAI-shaped base URL, authenticated by an API key.
32
+ */
33
+ export declare const modelCredentialPolicy: Readonly<{
34
+ fungi: {
35
+ api_key: {
36
+ local: Readonly<{
37
+ status: "forbidden";
38
+ reason: string;
39
+ source: string;
40
+ }>;
41
+ hosted: Readonly<{
42
+ status: "forbidden";
43
+ reason: string;
44
+ source: string;
45
+ }>;
46
+ };
47
+ subscription: {
48
+ local: Readonly<{
49
+ status: "allowed";
50
+ }>;
51
+ hosted: Readonly<{
52
+ status: "forbidden";
53
+ reason: string;
54
+ source: string;
55
+ }>;
56
+ };
57
+ };
58
+ anthropic: {
59
+ api_key: {
60
+ local: Readonly<{
61
+ status: "allowed";
62
+ }>;
63
+ hosted: Readonly<{
64
+ status: "allowed";
65
+ }>;
66
+ };
67
+ subscription: {
68
+ local: Readonly<{
69
+ status: "allowed";
70
+ }>;
71
+ hosted: Readonly<{
72
+ status: "allowed";
73
+ }>;
74
+ };
75
+ };
76
+ "openai-codex": {
77
+ api_key: {
78
+ local: Readonly<{
79
+ status: "forbidden";
80
+ reason: string;
81
+ source: string;
82
+ }>;
83
+ hosted: Readonly<{
84
+ status: "forbidden";
85
+ reason: string;
86
+ source: string;
87
+ }>;
88
+ };
89
+ subscription: {
90
+ local: Readonly<{
91
+ status: "forbidden";
92
+ reason: string;
93
+ source: string;
94
+ }>;
95
+ hosted: Readonly<{
96
+ status: "forbidden";
97
+ reason: string;
98
+ source: string;
99
+ }>;
100
+ };
101
+ };
102
+ openai: {
103
+ api_key: {
104
+ local: Readonly<{
105
+ status: "allowed";
106
+ }>;
107
+ hosted: Readonly<{
108
+ status: "allowed";
109
+ }>;
110
+ };
111
+ subscription: {
112
+ local: Readonly<{
113
+ status: "allowed";
114
+ }>;
115
+ hosted: {
116
+ status: "forbidden";
117
+ reason: string;
118
+ source: string;
119
+ };
120
+ };
121
+ };
122
+ openrouter: {
123
+ api_key: {
124
+ local: Readonly<{
125
+ status: "allowed";
126
+ }>;
127
+ hosted: Readonly<{
128
+ status: "allowed";
129
+ }>;
130
+ };
131
+ subscription: {
132
+ local: Readonly<{
133
+ status: "forbidden";
134
+ reason: string;
135
+ source: string;
136
+ }>;
137
+ hosted: Readonly<{
138
+ status: "forbidden";
139
+ reason: string;
140
+ source: string;
141
+ }>;
142
+ };
143
+ };
144
+ "openai-compatible": {
145
+ api_key: {
146
+ local: Readonly<{
147
+ status: "allowed";
148
+ }>;
149
+ hosted: Readonly<{
150
+ status: "allowed";
151
+ }>;
152
+ };
153
+ subscription: {
154
+ local: Readonly<{
155
+ status: "forbidden";
156
+ reason: string;
157
+ source: string;
158
+ }>;
159
+ hosted: Readonly<{
160
+ status: "forbidden";
161
+ reason: string;
162
+ source: string;
163
+ }>;
164
+ };
165
+ };
166
+ }>;
167
+ /**
168
+ * Per-provider off switch for subscription sign-in, local and hosted alike.
169
+ * Set an entry to `true` when a provider objects or starts refusing tokens.
170
+ */
171
+ export declare const subscriptionKillSwitch: Readonly<Record<string, boolean>>;
172
+ /**
173
+ * Read a runtime kill-switch override: a comma-separated provider list such as
174
+ * `anthropic,openai`. Hosts supply it from runtime configuration (a
175
+ * Worker secret or an environment variable), so turning a provider off needs
176
+ * no code change or release. An override can only switch providers off.
177
+ */
178
+ export declare function subscriptionKillSwitchFrom(raw: string | undefined): Readonly<Record<string, boolean>>;
179
+ /** The one decision every login surface and credential store consults. */
180
+ export declare function modelCredentialPermission(providerId: string, method: CredentialMethod, host: CredentialHost): CredentialPermission;
181
+ /** Who owns a credential: one Fungi user, or the whole Team. */
182
+ export type CredentialScope = "user" | "team";
183
+ export type LoginRequest = Readonly<{
184
+ providerId: string;
185
+ method: CredentialMethod;
186
+ host: CredentialHost;
187
+ scope: CredentialScope;
188
+ }>;
189
+ /**
190
+ * Whether this login may be offered or this credential attached. A
191
+ * subscription belongs to one person: it is never Team-scoped, shared or
192
+ * pooled, and a provider's kill switch turns it off. Unreviewed providers stay
193
+ * local.
194
+ */
195
+ export declare function loginAllowed(request: LoginRequest, killSwitch?: Readonly<Record<string, boolean>>): boolean;
196
+ /** The owner recorded with a stored credential. */
197
+ export type CredentialBinding = Readonly<{
198
+ scope: "user";
199
+ teamId: string;
200
+ userId: string;
201
+ }> | Readonly<{
202
+ scope: "team";
203
+ teamId: string;
204
+ }>;
205
+ /**
206
+ * The run asking to use a credential. An Agent or Computer acts for the user
207
+ * who owns it; a Team key is its own actor and owns no personal credential.
208
+ */
209
+ export type CredentialRunner = Readonly<{
210
+ teamId: string;
211
+ kind: "user";
212
+ userId: string;
213
+ }> | Readonly<{
214
+ teamId: string;
215
+ kind: "agent" | "computer";
216
+ ownerUserId: string;
217
+ }> | Readonly<{
218
+ teamId: string;
219
+ kind: "team-key";
220
+ }>;
221
+ /**
222
+ * Whether a run may use a stored credential. Team credentials serve any run in
223
+ * their Team; a user's credential serves only that user and their own Agents
224
+ * and Computers, never another member's run or a Team key.
225
+ */
226
+ export declare function credentialUsableBy(binding: CredentialBinding, runner: CredentialRunner): boolean;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Which model credentials Fungi may use, and where. This is the single owner of
3
+ * provider login policy: the local runtime, the TUI login registry and the Hub
4
+ * read it; none of them decides visibility on its own.
5
+ *
6
+ * Stance: like Pi and OpenCode, offer a person's own subscription locally and
7
+ * hosted, and turn a provider off through `subscriptionKillSwitch` if it
8
+ * objects or blocks tokens. A subscription is always one person's: never
9
+ * Team-scoped, shared or pooled. The new OpenAI ChatGPT plan flow is
10
+ * local-only; hosted use awaits OpenAI approval. The retired Codex provider
11
+ * is forbidden on both hosts.
12
+ * See wiki/3-resources/decisions/adr-byok-model-keys-v1.md.
13
+ */
14
+ const allowed = { status: "allowed" };
15
+ const unreviewed = { status: "unreviewed" };
16
+ const notOffered = {
17
+ status: "forbidden",
18
+ reason: "The provider does not offer this credential method",
19
+ source: "provider documentation",
20
+ };
21
+ const retiredCodex = {
22
+ status: "forbidden",
23
+ reason: "Use OpenAI instead of the retired OpenAI Codex provider",
24
+ source: "wiki/3-resources/decisions/adr-byok-model-keys-v1.md",
25
+ };
26
+ const apiKeyOnly = {
27
+ api_key: { local: allowed, hosted: allowed },
28
+ subscription: { local: notOffered, hosted: notOffered },
29
+ };
30
+ /**
31
+ * Reviewed providers, keyed by Pi provider id. `openai-compatible` is a
32
+ * Team-configured OpenAI-shaped base URL, authenticated by an API key.
33
+ */
34
+ export const modelCredentialPolicy = Object.freeze({
35
+ fungi: {
36
+ api_key: { local: notOffered, hosted: notOffered },
37
+ subscription: { local: allowed, hosted: notOffered },
38
+ },
39
+ anthropic: {
40
+ api_key: { local: allowed, hosted: allowed },
41
+ // Local: Pi's Claude Pro/Max OAuth, as Pi and OpenCode ship it. Hosted: the
42
+ // unmodified Claude Code binary on the owner's Computer, signed in by them.
43
+ subscription: { local: allowed, hosted: allowed },
44
+ },
45
+ "openai-codex": {
46
+ api_key: { local: retiredCodex, hosted: retiredCodex },
47
+ subscription: { local: retiredCodex, hosted: retiredCodex },
48
+ },
49
+ openai: {
50
+ api_key: { local: allowed, hosted: allowed },
51
+ subscription: {
52
+ local: allowed,
53
+ hosted: {
54
+ status: "forbidden",
55
+ reason: "ChatGPT plan usage in a hosted app requires OpenAI approval",
56
+ source: "https://developers.openai.com/siwc/token-sharing-open-source",
57
+ },
58
+ },
59
+ },
60
+ openrouter: apiKeyOnly,
61
+ "openai-compatible": apiKeyOnly,
62
+ });
63
+ const reviewedPolicies = new Map(Object.entries(modelCredentialPolicy));
64
+ /**
65
+ * Per-provider off switch for subscription sign-in, local and hosted alike.
66
+ * Set an entry to `true` when a provider objects or starts refusing tokens.
67
+ */
68
+ export const subscriptionKillSwitch = Object.freeze({ fungi: false, anthropic: false, openai: false });
69
+ /**
70
+ * Read a runtime kill-switch override: a comma-separated provider list such as
71
+ * `anthropic,openai`. Hosts supply it from runtime configuration (a
72
+ * Worker secret or an environment variable), so turning a provider off needs
73
+ * no code change or release. An override can only switch providers off.
74
+ */
75
+ export function subscriptionKillSwitchFrom(raw) {
76
+ const killed = (raw ?? "")
77
+ .split(",")
78
+ .map((entry) => entry.trim())
79
+ .filter((providerId) => /^[a-z0-9][a-z0-9-]{0,63}$/u.test(providerId))
80
+ .map((providerId) => [providerId, true]);
81
+ return Object.freeze({
82
+ ...subscriptionKillSwitch,
83
+ ...Object.fromEntries(killed),
84
+ });
85
+ }
86
+ /** The one decision every login surface and credential store consults. */
87
+ export function modelCredentialPermission(providerId, method, host) {
88
+ const policy = reviewedPolicies.get(providerId);
89
+ return policy?.[method][host] ?? unreviewed;
90
+ }
91
+ /**
92
+ * Whether this login may be offered or this credential attached. A
93
+ * subscription belongs to one person: it is never Team-scoped, shared or
94
+ * pooled, and a provider's kill switch turns it off. Unreviewed providers stay
95
+ * local.
96
+ */
97
+ export function loginAllowed(request, killSwitch = subscriptionKillSwitch) {
98
+ if (request.method === "subscription") {
99
+ if (request.scope !== "user")
100
+ return false;
101
+ if (killSwitch[request.providerId] === true)
102
+ return false;
103
+ }
104
+ const permission = modelCredentialPermission(request.providerId, request.method, request.host);
105
+ switch (permission.status) {
106
+ case "allowed":
107
+ return true;
108
+ case "forbidden":
109
+ return false;
110
+ case "unreviewed":
111
+ return request.host === "local";
112
+ }
113
+ }
114
+ /**
115
+ * Whether a run may use a stored credential. Team credentials serve any run in
116
+ * their Team; a user's credential serves only that user and their own Agents
117
+ * and Computers, never another member's run or a Team key.
118
+ */
119
+ export function credentialUsableBy(binding, runner) {
120
+ if (binding.teamId !== runner.teamId)
121
+ return false;
122
+ if (binding.scope === "team")
123
+ return true;
124
+ switch (runner.kind) {
125
+ case "user":
126
+ return runner.userId === binding.userId;
127
+ case "agent":
128
+ case "computer":
129
+ return runner.ownerUserId === binding.userId;
130
+ case "team-key":
131
+ return false;
132
+ }
133
+ }
@@ -0,0 +1,56 @@
1
+ import { type HostResolver } from "./model-key-network.js";
2
+ import { type ModelKeyCustodian, type ModelKeyProvider } from "./model-keys.js";
3
+ /**
4
+ * Model-key egress grants. The Team owner decides which runner may use which
5
+ * sealed key and issues a grant for each decision; the grant travels down in a
6
+ * secret-free projection to the runner's host. Only the custodian can open it:
7
+ * it is AES-GCM sealed under a key derived from the custodian's own credential,
8
+ * bound to the Team and runner, and expires. Opening it is the custodian's
9
+ * authorization check, so no request bytes return to the Team owner.
10
+ */
11
+ /** The runner a grant is bound to; the host supplies it from its own admission. */
12
+ export type ModelKeyGrantSubject = Readonly<{
13
+ kind: "agent" | "user" | "team-key";
14
+ id: string;
15
+ }>;
16
+ export type ModelKeyGrantClaims = Readonly<{
17
+ teamId: string;
18
+ keyId: string;
19
+ locator: string;
20
+ provider: ModelKeyProvider;
21
+ baseUrl: string;
22
+ ownerUserId: string | null;
23
+ expiresAt: number;
24
+ }>;
25
+ /** Issue and open grants under a key only the custodian's credential derives. */
26
+ export declare function createModelKeyGrants(options: Readonly<{
27
+ custodySecret: string;
28
+ now?: () => number;
29
+ }>): {
30
+ issue(subject: ModelKeyGrantSubject, claims: ModelKeyGrantClaims): Promise<string>;
31
+ /** The custodian's check: authentic, for this Team and runner, unexpired. */
32
+ open(grant: string, expected: Readonly<{
33
+ teamId: string;
34
+ subject: ModelKeyGrantSubject;
35
+ }>): Promise<ModelKeyGrantClaims>;
36
+ };
37
+ export type ModelKeyGrants = ReturnType<typeof createModelKeyGrants>;
38
+ /**
39
+ * Final model egress for a runner holding a grant. The custodian opens the
40
+ * grant, re-checks the personal key owner's membership and a Team-named base
41
+ * URL's public resolution, then attaches the sealed key upstream.
42
+ */
43
+ export declare function createModelKeyEgress(options: Readonly<{
44
+ grants: ModelKeyGrants;
45
+ custodian: () => ModelKeyCustodian;
46
+ /** Whether the personal key's owner is still a member of the Team. */
47
+ ownerIsMember: (teamId: string, userId: string) => Promise<"allowed" | "denied" | "unavailable">;
48
+ resolveHost?: HostResolver;
49
+ }>): {
50
+ send(grant: string, expected: Readonly<{
51
+ teamId: string;
52
+ subject: ModelKeyGrantSubject;
53
+ provider: ModelKeyProvider;
54
+ }>, request: Request): Promise<Response>;
55
+ };
56
+ export type ModelKeyEgress = ReturnType<typeof createModelKeyEgress>;