@fungi.computer/oyster 0.0.0-stage → 0.0.1
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/LICENSE +21 -0
- package/README.md +1082 -2
- package/dist/agents.d.ts +114 -0
- package/dist/agents.js +322 -0
- package/dist/app-builds.d.ts +151 -0
- package/dist/app-builds.js +355 -0
- package/dist/app-sandbox.d.ts +2 -0
- package/dist/app-sandbox.js +2 -0
- package/dist/app-sources.d.ts +46 -0
- package/dist/app-sources.js +335 -0
- package/dist/app-storage.d.ts +141 -0
- package/dist/app-storage.js +281 -0
- package/dist/apps/gate.d.ts +105 -0
- package/dist/apps/gate.js +273 -0
- package/dist/apps/index.d.ts +5 -0
- package/dist/apps/index.js +5 -0
- package/dist/apps/installation.d.ts +248 -0
- package/dist/apps/installation.js +307 -0
- package/dist/apps/operations.d.ts +8 -0
- package/dist/apps/operations.js +98 -0
- package/dist/apps/tools.d.ts +33 -0
- package/dist/apps/tools.js +17 -0
- package/dist/auth.d.ts +11 -0
- package/dist/auth.js +93 -0
- package/dist/backend-request.d.ts +3 -0
- package/dist/backend-request.js +53 -0
- package/dist/build/build-jobs.d.ts +128 -0
- package/dist/build/build-jobs.js +197 -0
- package/dist/build/build-step.d.ts +26 -0
- package/dist/build/build-step.js +20 -0
- package/dist/build/bundled-html.d.ts +7 -0
- package/dist/build/bundled-html.js +64 -0
- package/dist/build/definition-emission.d.ts +34 -0
- package/dist/build/definition-emission.js +201 -0
- package/dist/build/effect.d.ts +231 -0
- package/dist/build/effect.js +396 -0
- package/dist/build/git-environment.d.ts +6 -0
- package/dist/build/git-environment.js +48 -0
- package/dist/build/html-recipe.d.ts +20 -0
- package/dist/build/html-recipe.js +51 -0
- package/dist/build/local-build.d.ts +57 -0
- package/dist/build/local-build.js +1078 -0
- package/dist/build/r2-artifact-writer.d.ts +31 -0
- package/dist/build/r2-artifact-writer.js +179 -0
- package/dist/build/snapshot-package-store.d.ts +2 -0
- package/dist/build/snapshot-package-store.js +21 -0
- package/dist/build/snapshot-program-scripts.d.ts +6 -0
- package/dist/build/snapshot-program-scripts.js +2 -0
- package/dist/build/submission-contract.d.ts +384 -0
- package/dist/build/submission-contract.js +70 -0
- package/dist/build/workspace-build.d.ts +50 -0
- package/dist/build/workspace-build.js +156 -0
- package/dist/build-contract.d.ts +4 -0
- package/dist/build-contract.js +4 -0
- package/dist/build-jobs.d.ts +2 -0
- package/dist/build-jobs.js +2 -0
- package/dist/build.d.ts +11 -0
- package/dist/build.js +13 -0
- package/dist/capability-descriptor.d.ts +46 -0
- package/dist/capability-descriptor.js +51 -0
- package/dist/catalog-frame.d.ts +39 -0
- package/dist/catalog-frame.js +72 -0
- package/dist/cause-class.d.ts +19 -0
- package/dist/cause-class.js +54 -0
- package/dist/cloudflare.d.ts +123 -0
- package/dist/cloudflare.js +391 -0
- package/dist/computer-terminal.d.ts +38 -0
- package/dist/computer-terminal.js +410 -0
- package/dist/computer.d.ts +60 -0
- package/dist/computer.js +33 -0
- package/dist/event-recipients.d.ts +57 -0
- package/dist/event-recipients.js +144 -0
- package/dist/facet-invocation.d.ts +4 -0
- package/dist/facet-invocation.js +45 -0
- package/dist/files.d.ts +42 -0
- package/dist/files.js +123 -0
- package/dist/frame-binding-wire-script.d.ts +2 -0
- package/dist/frame-binding-wire-script.js +1 -0
- package/dist/frame-binding-wire.d.ts +28 -0
- package/dist/frame-binding-wire.js +54 -0
- package/dist/git-admission.d.ts +52 -0
- package/dist/git-admission.js +188 -0
- package/dist/git-auth.d.ts +11 -0
- package/dist/git-auth.js +27 -0
- package/dist/git-door.d.ts +35 -0
- package/dist/git-door.js +108 -0
- package/dist/git-journal.d.ts +31 -0
- package/dist/git-journal.js +188 -0
- package/dist/git.d.ts +31 -0
- package/dist/git.js +64 -0
- package/dist/guest-policy.d.ts +9 -0
- package/dist/guest-policy.js +13 -0
- package/dist/host.d.ts +244 -0
- package/dist/host.js +1043 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +320 -0
- package/dist/installation-consent.d.ts +79 -0
- package/dist/installation-consent.js +82 -0
- package/dist/internal/background-origins.d.ts +3 -0
- package/dist/internal/background-origins.js +8 -0
- package/dist/internal/delivery.d.ts +12 -0
- package/dist/internal/delivery.js +167 -0
- package/dist/internal/host-gate.d.ts +14 -0
- package/dist/internal/host-gate.js +62 -0
- package/dist/internal/registry-installations.d.ts +6 -0
- package/dist/internal/registry-installations.js +12 -0
- package/dist/invocation.d.ts +251 -0
- package/dist/invocation.js +377 -0
- package/dist/keys.d.ts +48 -0
- package/dist/keys.js +52 -0
- package/dist/mcp-owner.d.ts +27 -0
- package/dist/mcp-owner.js +143 -0
- package/dist/mcp.d.ts +89 -0
- package/dist/mcp.js +429 -0
- package/dist/media.d.ts +23 -0
- package/dist/media.js +12 -0
- package/dist/pack.d.ts +76 -0
- package/dist/pack.js +134 -0
- package/dist/page/capabilities.d.ts +23 -0
- package/dist/page/capabilities.js +256 -0
- package/dist/page/computer-handles.d.ts +79 -0
- package/dist/page/computer-handles.js +44 -0
- package/dist/page/lifetime.d.ts +6 -0
- package/dist/page/lifetime.js +19 -0
- package/dist/page/mount.d.ts +163 -0
- package/dist/page/mount.js +390 -0
- package/dist/page/storage.d.ts +3 -0
- package/dist/page/storage.js +82 -0
- package/dist/page/terminal.d.ts +5 -0
- package/dist/page/terminal.js +7 -0
- package/dist/page-script.d.ts +14 -0
- package/dist/page-script.js +5 -0
- package/dist/r2-files.d.ts +3 -0
- package/dist/r2-files.js +79 -0
- package/dist/release.d.ts +72 -0
- package/dist/release.js +164 -0
- package/dist/root-data.d.ts +86 -0
- package/dist/root-data.js +69 -0
- package/dist/shell/app-directory.d.ts +537 -0
- package/dist/shell/app-directory.js +195 -0
- package/dist/shell/browser-actions.d.ts +7 -0
- package/dist/shell/browser-actions.js +56 -0
- package/dist/shell/command-host.d.ts +112 -0
- package/dist/shell/command-host.js +502 -0
- package/dist/shell/commands.d.ts +25 -0
- package/dist/shell/commands.js +78 -0
- package/dist/shell/index.d.ts +8 -0
- package/dist/shell/index.js +8 -0
- package/dist/shell/keys.d.ts +41 -0
- package/dist/shell/keys.js +134 -0
- package/dist/shell/shell-module.d.ts +31 -0
- package/dist/shell/shell-module.js +37 -0
- package/dist/shell/window-manager.d.ts +124 -0
- package/dist/shell/window-manager.js +711 -0
- package/dist/shell/workbench-contract.d.ts +68 -0
- package/dist/shell/workbench-contract.js +1 -0
- package/dist/shell/workbench-host-types.d.ts +96 -0
- package/dist/shell/workbench-host-types.js +1 -0
- package/dist/shell/workbench-host.d.ts +34 -0
- package/dist/shell/workbench-host.js +347 -0
- package/dist/shell/workbench-model.d.ts +192 -0
- package/dist/shell/workbench-model.js +53 -0
- package/dist/shell/workbench.d.ts +4 -0
- package/dist/shell/workbench.js +2 -0
- package/dist/source/fork.d.ts +27 -0
- package/dist/source/fork.js +46 -0
- package/dist/source/index.d.ts +11 -0
- package/dist/source/index.js +11 -0
- package/dist/source/seed-receipt.d.ts +53 -0
- package/dist/source/seed-receipt.js +18 -0
- package/dist/source/team-source.d.ts +32 -0
- package/dist/source/team-source.js +42 -0
- package/dist/source/template-repository.d.ts +2 -0
- package/dist/source/template-repository.js +8 -0
- package/dist/source/template-write.d.ts +69 -0
- package/dist/source/template-write.js +344 -0
- package/dist/storage-frame.d.ts +64 -0
- package/dist/storage-frame.js +28 -0
- package/dist/tenant.d.ts +81 -0
- package/dist/tenant.js +71 -0
- package/dist/testing/capabilities.d.ts +29 -0
- package/dist/testing/capabilities.js +130 -0
- package/dist/testing/catalog.d.ts +3 -0
- package/dist/testing/catalog.js +53 -0
- package/dist/testing/index.d.ts +10 -0
- package/dist/testing/index.js +10 -0
- package/dist/testing/installation.d.ts +11 -0
- package/dist/testing/installation.js +25 -0
- package/dist/testing/native-worker.d.ts +21 -0
- package/dist/testing/native-worker.js +106 -0
- package/dist/testing/worker.d.ts +18 -0
- package/dist/testing/worker.js +67 -0
- package/dist/testing/workers-runtime.d.ts +24 -0
- package/dist/testing/workers-runtime.js +33 -0
- package/dist/webhook-delivery.d.ts +14 -0
- package/dist/webhook-delivery.js +17 -0
- package/dist/workers-git.d.ts +285 -0
- package/dist/workers-git.js +1174 -0
- package/dist/workers.d.ts +352 -0
- package/dist/workers.js +866 -0
- package/docs/api/README.md +82 -0
- package/docs/api/agents.md +332 -0
- package/docs/api/app-storage.md +209 -0
- package/docs/api/apps.md +1375 -0
- package/docs/api/auth.md +63 -0
- package/docs/api/build-contract.md +507 -0
- package/docs/api/build.md +1476 -0
- package/docs/api/cloudflare.md +421 -0
- package/docs/api/computer.md +203 -0
- package/docs/api/files.md +117 -0
- package/docs/api/git.md +470 -0
- package/docs/api/host.md +1328 -0
- package/docs/api/index.md +430 -0
- package/docs/api/invocation.md +738 -0
- package/docs/api/keys.md +175 -0
- package/docs/api/mcp.md +301 -0
- package/docs/api/media.md +40 -0
- package/docs/api/modules.md +42 -0
- package/docs/api/pack.md +138 -0
- package/docs/api/page/mount.md +298 -0
- package/docs/api/root-data.md +111 -0
- package/docs/api/shell/app-directory.md +332 -0
- package/docs/api/shell/command-host.md +224 -0
- package/docs/api/shell/commands.md +197 -0
- package/docs/api/shell/keys.md +183 -0
- package/docs/api/shell/shell-module.md +143 -0
- package/docs/api/shell/window-manager.md +267 -0
- package/docs/api/shell/workbench.md +186 -0
- package/docs/api/shell.md +1788 -0
- package/docs/api/tenant.md +194 -0
- package/docs/api/workers-git.md +1527 -0
- package/docs/api/workers.md +1678 -0
- package/docs/media/architecture.mmd +32 -0
- package/docs/media/architecture.png +0 -0
- package/docs/media/bonsai-network.png +0 -0
- package/docs/media/bonsai-repository.png +0 -0
- package/docs/site/agent-quickstart.mdx +349 -0
- package/examples/git/worker.ts +39 -0
- package/examples/git/wrangler.jsonc +32 -0
- package/examples/standalone/worker.ts +14 -0
- package/examples/standalone/wrangler.jsonc +21 -0
- package/package.json +249 -3
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `env.APP_STORAGE`: the App's own key/value store, as its backend receives
|
|
3
|
+
* it. One contract for every Oyster host (standalone and the Fungi Hub).
|
|
4
|
+
*
|
|
5
|
+
* - **Same handle everywhere.** Method handlers and lifecycle handlers (such
|
|
6
|
+
* as `onGitPush`) run in the same facet and receive the same `env`. When the
|
|
7
|
+
* Release declares the `APP_STORAGE` binding, both get this handle with the
|
|
8
|
+
* same operations.
|
|
9
|
+
* - **Who writes.** In a method call, a member writes their own `member`
|
|
10
|
+
* layer; an admitted owner may also write the `team` layer, as from the UI.
|
|
11
|
+
* A lifecycle handler has no
|
|
12
|
+
* member, so it writes the installation's shared `team` layer; a `member`
|
|
13
|
+
* write from a lifecycle handler is rejected with `owner_required`.
|
|
14
|
+
* - **Visible to watchers.** Every accepted write or reset is committed by the
|
|
15
|
+
* host's storage owner, bumps the key's revision and is published to
|
|
16
|
+
* `appStorage.watch` subscribers, exactly like a write from the App's UI.
|
|
17
|
+
* A push that writes a key therefore refreshes every open UI watching it.
|
|
18
|
+
* - **Same limits.** Declared keys, scopes, per-key size and quotas, and
|
|
19
|
+
* `basedOn` compare-and-set apply unchanged; rejections use Agar's
|
|
20
|
+
* `AppStorageError` codes.
|
|
21
|
+
*/
|
|
22
|
+
import * as Effect from "effect/Effect";
|
|
23
|
+
import * as Schema from "effect/Schema";
|
|
24
|
+
import { appStorageScopes } from "@fungi.computer/puffball";
|
|
25
|
+
import * as Stream from "effect/Stream";
|
|
26
|
+
import * as Queue from "effect/Queue";
|
|
27
|
+
import { AppStorageError, } from "@fungi.computer/agar/storage";
|
|
28
|
+
/** Whether `writer` may mutate `scope`; anything else is `owner_required`. */
|
|
29
|
+
export function appStorageWritable(writer, scope) {
|
|
30
|
+
return writer.kind === "member"
|
|
31
|
+
? scope === "member" || writer.isOwner
|
|
32
|
+
: scope === "team";
|
|
33
|
+
}
|
|
34
|
+
const unavailable = (cause) => cause instanceof AppStorageError
|
|
35
|
+
? cause
|
|
36
|
+
: new AppStorageError("unavailable", { cause });
|
|
37
|
+
const revision = Schema.Int.check(Schema.isGreaterThan(0));
|
|
38
|
+
// Team layers and installation rate counters use the existing non-member SQL partition.
|
|
39
|
+
const writerIdentity = (admission) => "memberId" in admission
|
|
40
|
+
? {
|
|
41
|
+
memberId: admission.memberId,
|
|
42
|
+
membershipVersion: admission.membershipVersion,
|
|
43
|
+
}
|
|
44
|
+
: { memberId: "", membershipVersion: 0 };
|
|
45
|
+
/** SQLite tables, indexes and initial revision state used by the App storage owner. */
|
|
46
|
+
export const appStorageSchemaStatements = [
|
|
47
|
+
`CREATE TABLE IF NOT EXISTS hub_app_storage (
|
|
48
|
+
address TEXT NOT NULL, app_id TEXT NOT NULL, key TEXT NOT NULL,
|
|
49
|
+
scope TEXT NOT NULL CHECK(scope IN ('team','member')), member_id TEXT NOT NULL, membership_version INTEGER NOT NULL,
|
|
50
|
+
value TEXT NOT NULL, revision INTEGER NOT NULL, bytes INTEGER NOT NULL,
|
|
51
|
+
PRIMARY KEY(address,app_id,key,scope,member_id,membership_version),
|
|
52
|
+
CHECK((scope='team' AND member_id='' AND membership_version=0) OR (scope='member' AND member_id<>'' AND membership_version>0))) STRICT`,
|
|
53
|
+
`CREATE INDEX IF NOT EXISTS hub_app_storage_member ON hub_app_storage(member_id) WHERE scope='member'`,
|
|
54
|
+
`CREATE TABLE IF NOT EXISTS hub_app_storage_clock (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL) STRICT`,
|
|
55
|
+
`INSERT OR IGNORE INTO hub_app_storage_clock VALUES(1,0)`,
|
|
56
|
+
`CREATE TABLE IF NOT EXISTS hub_app_storage_writes (address TEXT NOT NULL, member_id TEXT NOT NULL, membership_version INTEGER NOT NULL, started_at INTEGER NOT NULL, count INTEGER NOT NULL, PRIMARY KEY(address,member_id,membership_version)) STRICT`,
|
|
57
|
+
];
|
|
58
|
+
/** Called only inside the installation owner's delete transaction. */
|
|
59
|
+
export function purgeAppStorageInCommit(sql, address) {
|
|
60
|
+
sql.exec("DELETE FROM hub_app_storage WHERE address=?", address);
|
|
61
|
+
sql.exec("DELETE FROM hub_app_storage_writes WHERE address=?", address);
|
|
62
|
+
}
|
|
63
|
+
/** Called only inside the membership owner's removal transaction. */
|
|
64
|
+
export function purgeAppMemberStorageInCommit(sql, memberId, throughVersion) {
|
|
65
|
+
sql.exec("DELETE FROM hub_app_storage WHERE scope='member' AND member_id=? AND membership_version<=?", memberId, throughVersion);
|
|
66
|
+
sql.exec("DELETE FROM hub_app_storage_writes WHERE member_id=? AND membership_version<=?", memberId, throughVersion);
|
|
67
|
+
}
|
|
68
|
+
const storedLayer = Schema.Struct({
|
|
69
|
+
scope: Schema.Literals(appStorageScopes),
|
|
70
|
+
value: Schema.fromJsonString(Schema.Json),
|
|
71
|
+
revision,
|
|
72
|
+
bytes: Schema.Int,
|
|
73
|
+
});
|
|
74
|
+
const usageSchema = Schema.Struct({ bytes: Schema.Int, app_bytes: Schema.Int });
|
|
75
|
+
const clockSchema = Schema.Struct({ revision });
|
|
76
|
+
const appQuotaBytes = 1024 * 1024;
|
|
77
|
+
const teamQuotaBytes = 16 * appQuotaBytes;
|
|
78
|
+
const isJsonPrimitive = Schema.is(Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Null]));
|
|
79
|
+
function withinDepth(value, depth = 0) {
|
|
80
|
+
if (isJsonPrimitive(value))
|
|
81
|
+
return true;
|
|
82
|
+
if (depth >= 32)
|
|
83
|
+
return false;
|
|
84
|
+
return Object.values(value).every((child) => withinDepth(child, depth + 1));
|
|
85
|
+
}
|
|
86
|
+
/** One SQLite owner for scoped App values; its views bind the admitted member. */
|
|
87
|
+
export function createAppStorage(options) {
|
|
88
|
+
const { sql } = options;
|
|
89
|
+
const listeners = new Set();
|
|
90
|
+
const publish = (admission, input, result) => {
|
|
91
|
+
const notice = {
|
|
92
|
+
address: admission.address,
|
|
93
|
+
appId: admission.appId,
|
|
94
|
+
memberId: writerIdentity(admission).memberId,
|
|
95
|
+
key: input.key,
|
|
96
|
+
scope: input.scope,
|
|
97
|
+
revision: result.revision,
|
|
98
|
+
};
|
|
99
|
+
for (const listener of listeners)
|
|
100
|
+
listener(notice);
|
|
101
|
+
};
|
|
102
|
+
for (const statement of appStorageSchemaStatements)
|
|
103
|
+
sql.exec(statement);
|
|
104
|
+
const layers = (admission, key) => sql
|
|
105
|
+
.exec("SELECT scope,value,revision,bytes FROM hub_app_storage WHERE address=? AND app_id=? AND key=? AND (scope='team' OR (member_id=? AND membership_version=?))", admission.address, admission.appId, key, writerIdentity(admission).memberId, writerIdentity(admission).membershipVersion)
|
|
106
|
+
.toArray()
|
|
107
|
+
.map((row) => Schema.decodeUnknownSync(storedLayer)(row));
|
|
108
|
+
const declare = (admission, key) => {
|
|
109
|
+
const declared = admission.keys.find((entry) => entry.key === key);
|
|
110
|
+
if (declared === undefined)
|
|
111
|
+
throw new AppStorageError("undeclared_key");
|
|
112
|
+
admission.assertCurrent();
|
|
113
|
+
return declared;
|
|
114
|
+
};
|
|
115
|
+
const nextRevision = () => {
|
|
116
|
+
sql.exec("UPDATE hub_app_storage_clock SET revision=revision+1 WHERE id=1");
|
|
117
|
+
const row = sql
|
|
118
|
+
.exec("SELECT revision FROM hub_app_storage_clock WHERE id=1")
|
|
119
|
+
.toArray()[0];
|
|
120
|
+
return Schema.decodeUnknownSync(clockSchema)(row).revision;
|
|
121
|
+
};
|
|
122
|
+
const consumeWrite = (admission) => {
|
|
123
|
+
const now = (options.now ?? Date.now)();
|
|
124
|
+
const row = sql
|
|
125
|
+
.exec("SELECT started_at,count FROM hub_app_storage_writes WHERE address=? AND member_id=? AND membership_version=?", admission.address, writerIdentity(admission).memberId, writerIdentity(admission).membershipVersion)
|
|
126
|
+
.toArray()[0];
|
|
127
|
+
const saved = row === undefined
|
|
128
|
+
? null
|
|
129
|
+
: Schema.decodeUnknownSync(Schema.Struct({ started_at: Schema.Int, count: Schema.Int }))(row);
|
|
130
|
+
const current = saved !== null && now < saved.started_at + 60000;
|
|
131
|
+
if (current && saved.count >= 60)
|
|
132
|
+
throw new AppStorageError("quota_exceeded");
|
|
133
|
+
sql.exec("INSERT INTO hub_app_storage_writes(address,member_id,membership_version,started_at,count) VALUES(?,?,?,?,?) ON CONFLICT(address,member_id,membership_version) DO UPDATE SET started_at=excluded.started_at,count=excluded.count", admission.address, writerIdentity(admission).memberId, writerIdentity(admission).membershipVersion, current ? saved.started_at : now, current ? saved.count + 1 : 1);
|
|
134
|
+
};
|
|
135
|
+
const mutate = (admission, input, value) => {
|
|
136
|
+
const writer = "memberId" in admission
|
|
137
|
+
? {
|
|
138
|
+
kind: "member",
|
|
139
|
+
memberId: admission.memberId,
|
|
140
|
+
isOwner: admission.isOwner,
|
|
141
|
+
}
|
|
142
|
+
: { kind: "lifecycle", handler: "installation" };
|
|
143
|
+
if (!appStorageWritable(writer, input.scope))
|
|
144
|
+
throw new AppStorageError("owner_required");
|
|
145
|
+
const declared = declare(admission, input.key);
|
|
146
|
+
if (!declared.scopes.includes(input.scope))
|
|
147
|
+
throw new AppStorageError("scope_not_declared");
|
|
148
|
+
const current = layers(admission, input.key).find((row) => row.scope === input.scope);
|
|
149
|
+
if ((current?.revision ?? null) !== input.basedOn)
|
|
150
|
+
throw new AppStorageError("conflict");
|
|
151
|
+
const memberId = input.scope === "member" ? writerIdentity(admission).memberId : "";
|
|
152
|
+
const membershipVersion = input.scope === "member"
|
|
153
|
+
? writerIdentity(admission).membershipVersion
|
|
154
|
+
: 0;
|
|
155
|
+
if (value === undefined) {
|
|
156
|
+
consumeWrite(admission);
|
|
157
|
+
sql.exec("DELETE FROM hub_app_storage WHERE address=? AND app_id=? AND key=? AND scope=? AND member_id=? AND membership_version=?", admission.address, admission.appId, input.key, input.scope, memberId, membershipVersion);
|
|
158
|
+
return { revision: nextRevision() };
|
|
159
|
+
}
|
|
160
|
+
if (!withinDepth(value))
|
|
161
|
+
throw new AppStorageError("invalid");
|
|
162
|
+
const encoded = JSON.stringify(value);
|
|
163
|
+
const bytes = new TextEncoder().encode(encoded).byteLength;
|
|
164
|
+
if (bytes > declared.maxBytes)
|
|
165
|
+
throw new AppStorageError("too_large");
|
|
166
|
+
const usage = Schema.decodeUnknownSync(usageSchema)(sql
|
|
167
|
+
.exec("SELECT COALESCE(SUM(bytes),0) AS bytes, COALESCE(SUM(CASE WHEN address=? THEN bytes ELSE 0 END),0) AS app_bytes FROM hub_app_storage", admission.address)
|
|
168
|
+
.toArray()[0]);
|
|
169
|
+
const delta = bytes - (current?.bytes ?? 0);
|
|
170
|
+
if (usage.bytes + delta > teamQuotaBytes ||
|
|
171
|
+
usage.app_bytes + delta > appQuotaBytes)
|
|
172
|
+
throw new AppStorageError("quota_exceeded");
|
|
173
|
+
consumeWrite(admission);
|
|
174
|
+
const revision = nextRevision();
|
|
175
|
+
sql.exec("INSERT INTO hub_app_storage(address,app_id,key,scope,member_id,membership_version,value,revision,bytes) VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(address,app_id,key,scope,member_id,membership_version) DO UPDATE SET value=excluded.value,revision=excluded.revision,bytes=excluded.bytes", admission.address, admission.appId, input.key, input.scope, memberId, membershipVersion, encoded, revision, bytes);
|
|
176
|
+
return { revision };
|
|
177
|
+
};
|
|
178
|
+
const resolveStorage = (admission, key) => {
|
|
179
|
+
const declared = declare(admission, key);
|
|
180
|
+
const rows = layers(admission, key);
|
|
181
|
+
const team = declared.scopes.includes("team")
|
|
182
|
+
? rows.find((row) => row.scope === "team")
|
|
183
|
+
: undefined;
|
|
184
|
+
if (!("memberId" in admission) && !declared.scopes.includes("team"))
|
|
185
|
+
throw new AppStorageError("owner_required");
|
|
186
|
+
const member = "memberId" in admission && declared.scopes.includes("member")
|
|
187
|
+
? rows.find((row) => row.scope === "member")
|
|
188
|
+
: undefined;
|
|
189
|
+
const project = (row) => row === undefined ? null : { value: row.value, revision: row.revision };
|
|
190
|
+
return {
|
|
191
|
+
value: member !== undefined
|
|
192
|
+
? member.value
|
|
193
|
+
: team !== undefined
|
|
194
|
+
? team.value
|
|
195
|
+
: null,
|
|
196
|
+
source: member !== undefined ? "member" : team !== undefined ? "team" : "none",
|
|
197
|
+
team: project(team),
|
|
198
|
+
member: project(member),
|
|
199
|
+
canWriteTeam: "isOwner" in admission ? admission.isOwner : true,
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
const commitMutation = (admission, input, value) => Effect.uninterruptible(options
|
|
203
|
+
.commit(() => mutate(admission, input, value))
|
|
204
|
+
.pipe(Effect.tap((result) => Effect.sync(() => {
|
|
205
|
+
publish(admission, input, result);
|
|
206
|
+
}))));
|
|
207
|
+
return {
|
|
208
|
+
/** Remove an installation's values and write counters inside its delete transaction. */
|
|
209
|
+
purgeInstallation: (address) => {
|
|
210
|
+
purgeAppStorageInCommit(sql, address);
|
|
211
|
+
},
|
|
212
|
+
/** Remove a member's private values and counters through the revoked membership version. */
|
|
213
|
+
purgeMember: (memberId, throughVersion) => {
|
|
214
|
+
purgeAppMemberStorageInCommit(sql, memberId, throughVersion);
|
|
215
|
+
},
|
|
216
|
+
/** Bind a lifecycle view to fresh installation admission; only declared team layers are accessible. */
|
|
217
|
+
openInstallation(authorize) {
|
|
218
|
+
const mutation = (input, value) => authorize().pipe(Effect.flatMap((admission) => commitMutation(admission, input, value)), Effect.mapError(unavailable));
|
|
219
|
+
return {
|
|
220
|
+
/** Read the declared team layer without exposing any member's private value. */
|
|
221
|
+
read: (key) => authorize().pipe(Effect.flatMap((admission) => Effect.try({
|
|
222
|
+
try: () => resolveStorage(admission, key),
|
|
223
|
+
catch: unavailable,
|
|
224
|
+
})), Effect.mapError(unavailable)),
|
|
225
|
+
/** Commit a declared team-layer write and publish its new revision to watchers. */
|
|
226
|
+
write: (input) => mutation(input, input.value),
|
|
227
|
+
/** Reset a declared team layer; member scope is rejected with owner_required. */
|
|
228
|
+
reset: (input) => mutation(input, undefined),
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
/** Bind a member view to live admission, declared keys and the current membership version. */
|
|
232
|
+
open(authorize) {
|
|
233
|
+
const admitted = () => authorize().pipe(Effect.mapError(unavailable));
|
|
234
|
+
return {
|
|
235
|
+
/** Resolve declared member and team layers under current live admission. */
|
|
236
|
+
read: (key) => admitted().pipe(Effect.flatMap((admission) => Effect.try({
|
|
237
|
+
try: () => {
|
|
238
|
+
return resolveStorage(admission, key);
|
|
239
|
+
},
|
|
240
|
+
catch: unavailable,
|
|
241
|
+
}))),
|
|
242
|
+
/** Commit an admitted layer write with revision and quota checks, then notify watchers. */
|
|
243
|
+
write: (input) => admitted().pipe(Effect.flatMap((admission) => commitMutation(admission, input, input.value)), Effect.mapError(unavailable)),
|
|
244
|
+
/** Remove an admitted layer at its expected revision and notify watchers. */
|
|
245
|
+
reset: (input) => admitted().pipe(Effect.flatMap((admission) => commitMutation(admission, input, undefined)), Effect.mapError(unavailable)),
|
|
246
|
+
/** One bounded invalidation stream. Null marks an armed subscription. */
|
|
247
|
+
events: (key) => Stream.unwrap(Effect.gen(function* () {
|
|
248
|
+
const admission = yield* admitted();
|
|
249
|
+
const declared = yield* Effect.try({
|
|
250
|
+
try: () => declare(admission, key),
|
|
251
|
+
catch: unavailable,
|
|
252
|
+
});
|
|
253
|
+
const queue = yield* Queue.sliding(1);
|
|
254
|
+
const listener = (notice) => {
|
|
255
|
+
if (notice.address !== admission.address ||
|
|
256
|
+
notice.appId !== admission.appId ||
|
|
257
|
+
notice.key !== key ||
|
|
258
|
+
!declared.scopes.includes(notice.scope) ||
|
|
259
|
+
(notice.scope === "member" &&
|
|
260
|
+
notice.memberId !== admission.memberId))
|
|
261
|
+
return;
|
|
262
|
+
Queue.offerUnsafe(queue, {
|
|
263
|
+
appId: notice.appId,
|
|
264
|
+
key: notice.key,
|
|
265
|
+
scope: notice.scope,
|
|
266
|
+
revision: notice.revision,
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
yield* Effect.acquireRelease(Effect.sync(() => listeners.add(listener)), () => Effect.sync(() => {
|
|
270
|
+
listeners.delete(listener);
|
|
271
|
+
Queue.shutdownUnsafe(queue);
|
|
272
|
+
}));
|
|
273
|
+
return Stream.concat(Stream.succeed(null), Stream.fromQueue(queue)).pipe(Stream.tap(() => admitted().pipe(Effect.flatMap((current) => Effect.try({
|
|
274
|
+
try: () => declare(current, key),
|
|
275
|
+
catch: unavailable,
|
|
276
|
+
})))));
|
|
277
|
+
})),
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { type AppApi, type AppDeclaration, type HostActor } from "@fungi.computer/puffball/app";
|
|
3
|
+
import { type AppToolAccess } from "./tools.js";
|
|
4
|
+
import { type DeclaredOperations, type Installation, type ApprovedGrant } from "./installation.js";
|
|
5
|
+
/** Attested by the host, never authenticated by decoding request JSON. */
|
|
6
|
+
export type HostPrincipal = Readonly<{
|
|
7
|
+
actor: HostActor | Readonly<{
|
|
8
|
+
kind: "visitor";
|
|
9
|
+
}>;
|
|
10
|
+
role: "owner" | "member" | null;
|
|
11
|
+
}>;
|
|
12
|
+
/** Requests select existing SDK methods, opaque backend calls and derived operation identities. */
|
|
13
|
+
export declare const gateRequestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
14
|
+
kind: z.ZodLiteral<"method">;
|
|
15
|
+
name: z.ZodString;
|
|
16
|
+
input: z.ZodUnknown;
|
|
17
|
+
via: z.ZodEnum<{
|
|
18
|
+
ui: "ui";
|
|
19
|
+
agent: "agent";
|
|
20
|
+
mcp: "mcp";
|
|
21
|
+
workflow: "workflow";
|
|
22
|
+
}>;
|
|
23
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
24
|
+
kind: z.ZodLiteral<"capability-route">;
|
|
25
|
+
capability: z.ZodPipe<z.ZodUnknown, z.ZodTransform<"agent.list" | "agent.manage" | "agent.rename" | "agent.toolPolicy" | "agent.read" | "agent.send" | "computer.list" | "computer.readFile" | "computer.writeFile" | "computer.execute" | "llm.generation" | "media.generation" | "games:servers" | "git.repository" | "apps.open" | "apps.call" | "app.storage" | "app.events.emit" | "team.workbenchHost" | "marketplace.browse" | "marketplace.purchase" | "workflow.run" | "workflow.publish" | "workflow.arm" | "world" | "channels.read" | "channels.personal.manage", unknown>>;
|
|
26
|
+
operation: z.ZodString;
|
|
27
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
28
|
+
kind: z.ZodLiteral<"binding">;
|
|
29
|
+
binding: z.ZodPipe<z.ZodUnknown, z.ZodTransform<"BACKGROUND" | "GIT" | "EVENTS" | "APPS" | "WORKFLOWS" | "APP_STORAGE" | "MEDIA" | "COMPUTER" | "INBOX" | "AGENTS", unknown>>;
|
|
30
|
+
operation: z.ZodString;
|
|
31
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
32
|
+
kind: z.ZodLiteral<"list-tools">;
|
|
33
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
34
|
+
kind: z.ZodLiteral<"backend">;
|
|
35
|
+
}, z.core.$strict>], "kind">;
|
|
36
|
+
/** One decoded method, route, binding or catalog admission request. */
|
|
37
|
+
export type GateRequest = z.output<typeof gateRequestSchema>;
|
|
38
|
+
/** Immutable values the host already read; resource/grantor freshness remains host-owned. */
|
|
39
|
+
export type GateFacts = Readonly<{
|
|
40
|
+
principal: HostPrincipal;
|
|
41
|
+
installation: Installation;
|
|
42
|
+
declaration: AppDeclaration;
|
|
43
|
+
declaredOperations: DeclaredOperations;
|
|
44
|
+
api: AppApi | null;
|
|
45
|
+
access: AppToolAccess | null;
|
|
46
|
+
pins?: Readonly<{
|
|
47
|
+
authorityVersion: number;
|
|
48
|
+
artifactDigest: string;
|
|
49
|
+
}>;
|
|
50
|
+
}>;
|
|
51
|
+
/** Binding grants have the exact S2 InvocationFacts shape and its maintained registry grammar. */
|
|
52
|
+
export declare const invocationGrantsSchema: z.ZodReadonly<z.ZodArray<z.ZodObject<{
|
|
53
|
+
binding: z.ZodPipe<z.ZodUnknown, z.ZodTransform<"BACKGROUND" | "GIT" | "EVENTS" | "APPS" | "WORKFLOWS" | "APP_STORAGE" | "MEDIA" | "COMPUTER" | "INBOX" | "AGENTS", unknown>>;
|
|
54
|
+
operations: z.ZodReadonly<z.ZodArray<z.ZodString>>;
|
|
55
|
+
}, z.core.$strict>>>;
|
|
56
|
+
/** The explicit binding ceiling passed to an invocation frame. */
|
|
57
|
+
export type InvocationGrants = z.output<typeof invocationGrantsSchema>;
|
|
58
|
+
/** A declared method from the canonical Puffball API grammar. */
|
|
59
|
+
export type AppMethodDeclaration = AppApi["methods"][string];
|
|
60
|
+
/** Ordered, closed refusal vocabulary; external methods always require approval. */
|
|
61
|
+
export declare const gateRefusalSchema: z.ZodObject<{
|
|
62
|
+
kind: z.ZodLiteral<"refused">;
|
|
63
|
+
code: z.ZodEnum<{
|
|
64
|
+
unavailable: "unavailable";
|
|
65
|
+
stale_authority: "stale_authority";
|
|
66
|
+
not_declared: "not_declared";
|
|
67
|
+
not_approved: "not_approved";
|
|
68
|
+
level_too_low: "level_too_low";
|
|
69
|
+
approval_required: "approval_required";
|
|
70
|
+
requires_not_granted: "requires_not_granted";
|
|
71
|
+
invalid_input: "invalid_input";
|
|
72
|
+
visitor_read_only: "visitor_read_only";
|
|
73
|
+
}>;
|
|
74
|
+
}, z.core.$strict>;
|
|
75
|
+
/** Either an admitted scoped ceiling or one closed refusal code. */
|
|
76
|
+
export type GateDecision = Readonly<{
|
|
77
|
+
kind: "admitted";
|
|
78
|
+
grants: InvocationGrants;
|
|
79
|
+
method?: AppMethodDeclaration;
|
|
80
|
+
}> | z.output<typeof gateRefusalSchema>;
|
|
81
|
+
/** Intersect stored approvals with the current verified Release without widening resource selectors. */
|
|
82
|
+
export declare function grantsFor(installation: Installation, declaration: AppDeclaration, snapshot: DeclaredOperations, capability: ApprovedGrant["capability"]): ApprovedGrant | null;
|
|
83
|
+
/** Approved binding operations from an already verified declaration. Hosts still pin
|
|
84
|
+
* the selection/version/artifact and compare running frame grants as a subset. */
|
|
85
|
+
export declare function approvedBindingGrants(installation: Installation, declaration: AppDeclaration): InvocationGrants;
|
|
86
|
+
/** Narrow captured binding operations to the method's verified requirements and effect.
|
|
87
|
+
* The captured set is the ceiling; this never adds an operation or resource authority. */
|
|
88
|
+
export declare function scopedBindings(captured: InvocationGrants, method?: AppMethodDeclaration, ceiling?: "read" | "write"): InvocationGrants;
|
|
89
|
+
/** Principal ceiling for invocation and metadata capture. Captures must pass this
|
|
90
|
+
* to scopedBindings; its default write ceiling does not encode caller access. */
|
|
91
|
+
export declare function appInvocationCeiling(facts: Pick<GateFacts, "principal" | "access">): "read" | "write";
|
|
92
|
+
/** Permission metadata only: this is neither an invocation grant nor input admission. */
|
|
93
|
+
export type AppMethodMetadataDecision = Readonly<{
|
|
94
|
+
kind: "admitted";
|
|
95
|
+
method: AppMethodDeclaration;
|
|
96
|
+
}> | z.output<typeof gateRefusalSchema>;
|
|
97
|
+
/** Check a verified method's Release, principal, effect and real capability consent.
|
|
98
|
+
* Captures have no mapped runtime input yet. This result cannot authorize execution:
|
|
99
|
+
* dispatch must still use admits, including its ordinary input validation.
|
|
100
|
+
* Input schemas are not compiled here; execution may still refuse invalid_input.
|
|
101
|
+
* Project captured bindings with appInvocationCeiling(facts).
|
|
102
|
+
*/
|
|
103
|
+
export declare function admitMethodMetadata(facts: GateFacts, name: string): AppMethodMetadataDecision;
|
|
104
|
+
/** Ordered admission only. Host freshness/resource admission and effect execution stay outside this pure gate. */
|
|
105
|
+
export declare function admits(facts: GateFacts, rawRequest: GateRequest): GateDecision;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { AppInstanceError } from "../index.js";
|
|
2
|
+
import { appMethodSchemas } from "@fungi.computer/forage";
|
|
3
|
+
import { bindingOperationKey } from "./operations.js";
|
|
4
|
+
import * as z from "zod";
|
|
5
|
+
import * as Schema from "effect/Schema";
|
|
6
|
+
import { appBackendBindingKinds, appBackendBindingOperation, scopeOperationToMethod, appApiMethodNameSchema, appBackendBindingsSchema, appCapabilityIdSchema, } from "@fungi.computer/puffball/app";
|
|
7
|
+
import { appToolAccessSchema } from "./tools.js";
|
|
8
|
+
import { ownerCodec, } from "./installation.js";
|
|
9
|
+
const bindingNameSchema = ownerCodec(Schema.Literals(Object.values(appBackendBindingKinds).map((kind) => kind.name)));
|
|
10
|
+
/** Requests select existing SDK methods, opaque backend calls and derived operation identities. */
|
|
11
|
+
export const gateRequestSchema = z.discriminatedUnion("kind", [
|
|
12
|
+
z.strictObject({
|
|
13
|
+
kind: z.literal("method"),
|
|
14
|
+
name: z.string(),
|
|
15
|
+
input: z.unknown(),
|
|
16
|
+
via: z.enum(["ui", "agent", "mcp", "workflow"]),
|
|
17
|
+
}),
|
|
18
|
+
z.strictObject({
|
|
19
|
+
kind: z.literal("capability-route"),
|
|
20
|
+
capability: ownerCodec(appCapabilityIdSchema),
|
|
21
|
+
operation: z.string().min(1),
|
|
22
|
+
}),
|
|
23
|
+
z.strictObject({
|
|
24
|
+
kind: z.literal("binding"),
|
|
25
|
+
binding: bindingNameSchema,
|
|
26
|
+
operation: z.string().min(1),
|
|
27
|
+
}),
|
|
28
|
+
z.strictObject({ kind: z.literal("list-tools") }),
|
|
29
|
+
z.strictObject({ kind: z.literal("backend") }),
|
|
30
|
+
]);
|
|
31
|
+
/** Binding grants have the exact S2 InvocationFacts shape and its maintained registry grammar. */
|
|
32
|
+
export const invocationGrantsSchema = z
|
|
33
|
+
.array(z.strictObject({
|
|
34
|
+
binding: bindingNameSchema,
|
|
35
|
+
operations: z.array(z.string().min(1)).readonly(),
|
|
36
|
+
}))
|
|
37
|
+
.refine((grants) => Schema.is(appBackendBindingsSchema)(grants.map((grant) => ({
|
|
38
|
+
name: grant.binding,
|
|
39
|
+
operations: grant.operations,
|
|
40
|
+
}))), "Invalid declared binding operations")
|
|
41
|
+
.readonly();
|
|
42
|
+
/** Ordered, closed refusal vocabulary; external methods always require approval. */
|
|
43
|
+
export const gateRefusalSchema = z.strictObject({
|
|
44
|
+
kind: z.literal("refused"),
|
|
45
|
+
code: z.enum([
|
|
46
|
+
"unavailable",
|
|
47
|
+
"stale_authority",
|
|
48
|
+
"not_declared",
|
|
49
|
+
"not_approved",
|
|
50
|
+
"level_too_low",
|
|
51
|
+
"approval_required",
|
|
52
|
+
"requires_not_granted",
|
|
53
|
+
"invalid_input",
|
|
54
|
+
"visitor_read_only",
|
|
55
|
+
]),
|
|
56
|
+
});
|
|
57
|
+
/** Intersect stored approvals with the current verified Release without widening resource selectors. */
|
|
58
|
+
export function grantsFor(installation, declaration, snapshot, capability) {
|
|
59
|
+
if (installation.status !== "enabled" ||
|
|
60
|
+
snapshot.digest !== installation.declaredOperationsDigest ||
|
|
61
|
+
snapshot.artifactDigest !== installation.release.artifact.artifactDigest ||
|
|
62
|
+
!declaration.capabilities.includes(capability))
|
|
63
|
+
return null;
|
|
64
|
+
const approved = installation.approvedGrants.find((grant) => grant.capability === capability);
|
|
65
|
+
const declared = snapshot.capabilities.find((row) => row.capability === capability)
|
|
66
|
+
?.operations ?? [];
|
|
67
|
+
if (!approved?.capabilityApproved ||
|
|
68
|
+
(approved.resources !== "tenant-scope" && approved.resources.length === 0))
|
|
69
|
+
return null;
|
|
70
|
+
const operations = approved.operations.filter((operation) => declared.includes(operation));
|
|
71
|
+
return { ...approved, operations };
|
|
72
|
+
}
|
|
73
|
+
/** Approved binding operations from an already verified declaration. Hosts still pin
|
|
74
|
+
* the selection/version/artifact and compare running frame grants as a subset. */
|
|
75
|
+
export function approvedBindingGrants(installation, declaration) {
|
|
76
|
+
if (installation.status !== "enabled")
|
|
77
|
+
return [];
|
|
78
|
+
const bindings = [];
|
|
79
|
+
for (const binding of declaration.backend?.bindings ?? []) {
|
|
80
|
+
const operations = binding.operations.filter((name) => {
|
|
81
|
+
const operation = appBackendBindingOperation(binding.name, name);
|
|
82
|
+
return (operation !== undefined &&
|
|
83
|
+
operation.requires.length > 0 &&
|
|
84
|
+
operation.requires.every((capability) => {
|
|
85
|
+
const approved = installation.approvedGrants.find((grant) => grant.capability === capability);
|
|
86
|
+
return (declaration.capabilities.includes(capability) &&
|
|
87
|
+
approved?.capabilityApproved &&
|
|
88
|
+
(approved.resources === "tenant-scope" ||
|
|
89
|
+
approved.resources.length > 0) &&
|
|
90
|
+
approved.operations.includes(bindingOperationKey(binding.name, name)));
|
|
91
|
+
}));
|
|
92
|
+
});
|
|
93
|
+
if (operations.length > 0)
|
|
94
|
+
bindings.push({ binding: binding.name, operations });
|
|
95
|
+
}
|
|
96
|
+
return bindings;
|
|
97
|
+
}
|
|
98
|
+
/** Narrow captured binding operations to the method's verified requirements and effect.
|
|
99
|
+
* The captured set is the ceiling; this never adds an operation or resource authority. */
|
|
100
|
+
export function scopedBindings(captured, method, ceiling = "write") {
|
|
101
|
+
return captured.flatMap((binding) => {
|
|
102
|
+
const operations = binding.operations.filter((name) => {
|
|
103
|
+
const operation = appBackendBindingOperation(binding.binding, name);
|
|
104
|
+
if (operation === undefined)
|
|
105
|
+
throw new AppInstanceError("invalid_invocation_facts");
|
|
106
|
+
return scopeOperationToMethod(operation, method, ceiling);
|
|
107
|
+
});
|
|
108
|
+
return operations.length > 0
|
|
109
|
+
? [{ binding: binding.binding, operations }]
|
|
110
|
+
: [];
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** Principal ceiling for invocation and metadata capture. Captures must pass this
|
|
114
|
+
* to scopedBindings; its default write ceiling does not encode caller access. */
|
|
115
|
+
export function appInvocationCeiling(facts) {
|
|
116
|
+
return facts.access?.level === "read" ||
|
|
117
|
+
facts.principal.actor.kind === "visitor"
|
|
118
|
+
? "read"
|
|
119
|
+
: "write";
|
|
120
|
+
}
|
|
121
|
+
function admittedBindings(facts, method) {
|
|
122
|
+
return scopedBindings(approvedBindingGrants(facts.installation, facts.declaration), method, appInvocationCeiling(facts));
|
|
123
|
+
}
|
|
124
|
+
const refusal = (code) => ({ kind: "refused", code });
|
|
125
|
+
function admissionRefusal(facts) {
|
|
126
|
+
const { installation, principal } = facts;
|
|
127
|
+
const visitor = principal.actor.kind === "visitor";
|
|
128
|
+
if (installation.status !== "enabled" ||
|
|
129
|
+
(visitor && installation.visibility !== "public"))
|
|
130
|
+
return refusal("unavailable");
|
|
131
|
+
if (facts.declaredOperations.digest !== installation.declaredOperationsDigest ||
|
|
132
|
+
facts.declaredOperations.artifactDigest !==
|
|
133
|
+
installation.release.artifact.artifactDigest ||
|
|
134
|
+
(facts.pins !== undefined &&
|
|
135
|
+
(facts.pins.authorityVersion !== installation.authorityVersion ||
|
|
136
|
+
facts.pins.artifactDigest !==
|
|
137
|
+
installation.release.artifact.artifactDigest)))
|
|
138
|
+
return refusal("stale_authority");
|
|
139
|
+
if ((principal.actor.kind === "agent" || principal.actor.kind === "team-key") &&
|
|
140
|
+
!Schema.is(appToolAccessSchema)(facts.access))
|
|
141
|
+
return refusal("not_approved");
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
function methodMetadata(facts, name) {
|
|
145
|
+
const { installation, principal } = facts;
|
|
146
|
+
const visitor = principal.actor.kind === "visitor";
|
|
147
|
+
if (installation.instanceId === null)
|
|
148
|
+
return refusal("unavailable");
|
|
149
|
+
if (!Schema.is(appApiMethodNameSchema)(name) ||
|
|
150
|
+
facts.api === null ||
|
|
151
|
+
!Object.hasOwn(facts.api.methods, name))
|
|
152
|
+
return refusal("not_declared");
|
|
153
|
+
const method = facts.api.methods[name];
|
|
154
|
+
if (method === undefined)
|
|
155
|
+
return refusal("not_declared");
|
|
156
|
+
if (method.effect === "external")
|
|
157
|
+
return refusal("approval_required");
|
|
158
|
+
if (visitor && method.effect !== "read")
|
|
159
|
+
return refusal("visitor_read_only");
|
|
160
|
+
if (method.effect === "write" && facts.access?.level === "read")
|
|
161
|
+
return refusal("level_too_low");
|
|
162
|
+
return { kind: "admitted", method };
|
|
163
|
+
}
|
|
164
|
+
function methodGrantRefusal(facts, method) {
|
|
165
|
+
const { installation } = facts;
|
|
166
|
+
if (method.requires.some((capability) => grantsFor(installation, facts.declaration, facts.declaredOperations, capability) === null))
|
|
167
|
+
return refusal("requires_not_granted");
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
/** Check a verified method's Release, principal, effect and real capability consent.
|
|
171
|
+
* Captures have no mapped runtime input yet. This result cannot authorize execution:
|
|
172
|
+
* dispatch must still use admits, including its ordinary input validation.
|
|
173
|
+
* Input schemas are not compiled here; execution may still refuse invalid_input.
|
|
174
|
+
* Project captured bindings with appInvocationCeiling(facts).
|
|
175
|
+
*/
|
|
176
|
+
export function admitMethodMetadata(facts, name) {
|
|
177
|
+
const denied = admissionRefusal(facts);
|
|
178
|
+
if (denied !== null)
|
|
179
|
+
return denied;
|
|
180
|
+
const metadata = methodMetadata(facts, name);
|
|
181
|
+
if (metadata.kind === "refused")
|
|
182
|
+
return metadata;
|
|
183
|
+
return methodGrantRefusal(facts, metadata.method) ?? metadata;
|
|
184
|
+
}
|
|
185
|
+
/** Ordered admission only. Host freshness/resource admission and effect execution stay outside this pure gate. */
|
|
186
|
+
export function admits(facts, rawRequest) {
|
|
187
|
+
const parsed = gateRequestSchema.safeParse(rawRequest);
|
|
188
|
+
if (!parsed.success)
|
|
189
|
+
return refusal("invalid_input");
|
|
190
|
+
const request = parsed.data;
|
|
191
|
+
const { installation, principal } = facts;
|
|
192
|
+
const visitor = principal.actor.kind === "visitor";
|
|
193
|
+
const denied = admissionRefusal(facts);
|
|
194
|
+
if (denied !== null)
|
|
195
|
+
return denied;
|
|
196
|
+
switch (request.kind) {
|
|
197
|
+
case "backend":
|
|
198
|
+
// Opaque App requests have no verified method selector; they require write authority.
|
|
199
|
+
if (visitor)
|
|
200
|
+
return refusal("visitor_read_only");
|
|
201
|
+
if (installation.instanceId === null)
|
|
202
|
+
return refusal("unavailable");
|
|
203
|
+
if (facts.access?.level === "read")
|
|
204
|
+
return refusal("level_too_low");
|
|
205
|
+
return { kind: "admitted", grants: admittedBindings(facts) };
|
|
206
|
+
case "method": {
|
|
207
|
+
const metadata = methodMetadata(facts, request.name);
|
|
208
|
+
if (metadata.kind === "refused")
|
|
209
|
+
return metadata;
|
|
210
|
+
const { method } = metadata;
|
|
211
|
+
// The maintained JSON-Schema compiler can reject unsupported declared schemas.
|
|
212
|
+
// Return the typed input refusal; never leak compiler diagnostics or throw from admission.
|
|
213
|
+
try {
|
|
214
|
+
const input = appMethodSchemas({ methods: { [request.name]: method } })[request.name]?.input;
|
|
215
|
+
if (!input?.safeParse(request.input).success)
|
|
216
|
+
return refusal("invalid_input");
|
|
217
|
+
if (new TextEncoder().encode(JSON.stringify(request.input)).byteLength >
|
|
218
|
+
64 * 1024)
|
|
219
|
+
return refusal("invalid_input");
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return refusal("invalid_input");
|
|
223
|
+
}
|
|
224
|
+
const grantDenied = methodGrantRefusal(facts, method);
|
|
225
|
+
if (grantDenied !== null)
|
|
226
|
+
return grantDenied;
|
|
227
|
+
return {
|
|
228
|
+
kind: "admitted",
|
|
229
|
+
grants: admittedBindings(facts, method),
|
|
230
|
+
method,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
case "capability-route": {
|
|
234
|
+
if (visitor)
|
|
235
|
+
return refusal("visitor_read_only");
|
|
236
|
+
if (!request.operation.startsWith("route:") ||
|
|
237
|
+
!facts.declaration.capabilities.includes(request.capability) ||
|
|
238
|
+
facts.declaredOperations.capabilities
|
|
239
|
+
.find((row) => row.capability === request.capability)
|
|
240
|
+
?.operations.includes(request.operation) !== true)
|
|
241
|
+
return refusal("not_declared");
|
|
242
|
+
if (grantsFor(installation, facts.declaration, facts.declaredOperations, request.capability)?.operations.includes(request.operation) !== true)
|
|
243
|
+
return refusal("not_approved");
|
|
244
|
+
return { kind: "admitted", grants: [] };
|
|
245
|
+
}
|
|
246
|
+
case "binding": {
|
|
247
|
+
if (visitor)
|
|
248
|
+
return refusal("visitor_read_only");
|
|
249
|
+
if (installation.instanceId === null)
|
|
250
|
+
return refusal("unavailable");
|
|
251
|
+
const operation = appBackendBindingOperation(request.binding, request.operation);
|
|
252
|
+
if (operation === undefined ||
|
|
253
|
+
facts.declaration.backend?.bindings
|
|
254
|
+
.find((binding) => binding.name === request.binding)
|
|
255
|
+
?.operations.includes(request.operation) !== true ||
|
|
256
|
+
!operation.requires.every((capability) => facts.declaration.capabilities.includes(capability)))
|
|
257
|
+
return refusal("not_declared");
|
|
258
|
+
if (operation.effect === "write" && facts.access?.level === "read")
|
|
259
|
+
return refusal("level_too_low");
|
|
260
|
+
if (operation.requires.length < 1 ||
|
|
261
|
+
!operation.requires.every((capability) => grantsFor(installation, facts.declaration, facts.declaredOperations, capability)?.operations.includes(bindingOperationKey(request.binding, request.operation)) === true))
|
|
262
|
+
return refusal("not_approved");
|
|
263
|
+
return {
|
|
264
|
+
kind: "admitted",
|
|
265
|
+
grants: [{ binding: request.binding, operations: [request.operation] }],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
case "list-tools":
|
|
269
|
+
return facts.api === null || installation.instanceId === null
|
|
270
|
+
? refusal("unavailable")
|
|
271
|
+
: { kind: "admitted", grants: admittedBindings(facts) };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Installation records and pure admission rules; tenant state and commits stay host-owned. */
|
|
2
|
+
export { applyInstallationCommand, approvedGrantSchema, declaredOperationsSchema, installationCommandSchema, installationIdSchema, installationRefusalSchema, installationSchema, type Approval, type ApprovedGrant, type CapabilityOperationDescriptor, type DeclaredOperations, type GrantRequest, type Installation, type InstallationCommand, type InstallationContext, type InstallationReader, type InstallationRefusal, type InstallationUpdate, type ResourceRef, } from "./installation.js";
|
|
3
|
+
export { admits, admitMethodMetadata, appInvocationCeiling, approvedBindingGrants, scopedBindings, grantsFor, gateRefusalSchema, gateRequestSchema, invocationGrantsSchema, type AppMethodDeclaration, type AppMethodMetadataDecision, type GateDecision, type GateFacts, type GateRequest, type HostPrincipal, type InvocationGrants, } from "./gate.js";
|
|
4
|
+
export { deriveDeclaredOperations, appCallOperation } from "./operations.js";
|
|
5
|
+
export { appToolPolicySchema, type AppToolAccess, type AppToolEntry, type AppToolPolicy, } from "./tools.js";
|