@fungi.computer/oyster 0.0.1 → 0.2.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.
- package/README.md +228 -176
- package/dist/agent-avatars.d.ts +103 -0
- package/dist/agent-avatars.js +66 -0
- package/dist/agents.d.ts +12 -2
- package/dist/agents.js +39 -2
- package/dist/app-builds.d.ts +76 -17
- package/dist/app-builds.js +207 -63
- package/dist/app-sources.d.ts +7 -22
- package/dist/app-sources.js +49 -14
- package/dist/apps/gate.d.ts +7 -7
- package/dist/apps/gate.js +6 -6
- package/dist/apps/installation.d.ts +35 -35
- package/dist/apps/installation.js +20 -19
- package/dist/build/artifact-writer.d.ts +40 -0
- package/dist/build/artifact-writer.js +181 -0
- package/dist/build/build-jobs.js +7 -1
- package/dist/build/build-policy.d.ts +10 -0
- package/dist/build/build-policy.js +10 -0
- package/dist/build/build-step.d.ts +12 -1
- package/dist/build/build-step.js +9 -1
- package/dist/build/candidate-builder.d.ts +68 -0
- package/dist/build/candidate-builder.js +91 -0
- package/dist/build/candidate-cli.d.ts +1 -0
- package/dist/build/candidate-cli.js +119 -0
- package/dist/build/candidate-cli.mjs +28220 -0
- package/dist/build/content-objects.d.ts +37 -0
- package/dist/build/content-objects.js +53 -0
- package/dist/build/effect.d.ts +18 -0
- package/dist/build/effect.js +52 -12
- package/dist/build/git-environment.d.ts +3 -0
- package/dist/build/git-environment.js +21 -9
- package/dist/build/local-build.d.ts +13 -3
- package/dist/build/local-build.js +149 -99
- package/dist/build/snapshot-package-store.d.ts +10 -0
- package/dist/build/snapshot-package-store.js +15 -0
- package/dist/build/snapshot-program-scripts.js +1 -1
- package/dist/build/snapshot-programs/snapshot-path.d.ts +2 -0
- package/dist/build/snapshot-programs/snapshot-path.js +6 -0
- package/dist/build/submission-contract.d.ts +461 -17
- package/dist/build/submission-contract.js +136 -26
- package/dist/build/workspace-build.d.ts +7 -2
- package/dist/build/workspace-build.js +55 -11
- package/dist/build-contract.d.ts +3 -0
- package/dist/build-contract.js +3 -0
- package/dist/build-egress.d.ts +62 -0
- package/dist/build-egress.js +148 -0
- package/dist/build.d.ts +1 -0
- package/dist/build.js +1 -0
- package/dist/cloudflare.js +4 -4
- package/dist/frame-binding-wire-script.js +1 -1
- package/dist/frame-binding-wire.d.ts +1 -1
- package/dist/frame-binding-wire.js +1 -1
- package/dist/git-journal.js +4 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/installation-consent.d.ts +3 -1
- package/dist/installation-consent.js +2 -0
- package/dist/mcp-owner.js +20 -8
- package/dist/pack.d.ts +6 -3
- package/dist/pack.js +28 -11
- package/dist/page/gill-observation.d.ts +4 -0
- package/dist/page/gill-observation.js +31 -0
- package/dist/page/mount.d.ts +4 -8
- package/dist/page/window-launch.d.ts +18 -0
- package/dist/page/window-launch.js +11 -0
- package/dist/page-script.js +4 -4
- package/dist/release.d.ts +11 -0
- package/dist/release.js +4 -1
- package/dist/shell/app-directory.d.ts +50 -45
- package/dist/shell/app-directory.js +19 -18
- package/dist/shell/shell-module.d.ts +26 -2
- package/dist/shell/shell-module.js +51 -2
- package/dist/shell/window-manager.d.ts +5 -1
- package/dist/shell/window-manager.js +21 -2
- package/dist/shell/workbench-directory.d.ts +332 -0
- package/dist/shell/workbench-directory.js +35 -0
- package/dist/shell/workbench-host-types.d.ts +1 -1
- package/dist/shell/workbench-host.js +5 -0
- package/dist/shell/workbench-model.d.ts +3 -1
- package/dist/source/index.d.ts +6 -7
- package/dist/source/index.js +6 -7
- package/dist/source/template-push.d.ts +33 -0
- package/dist/source/template-push.js +223 -0
- package/dist/source/template-repository.d.ts +2 -0
- package/dist/source/template-repository.js +13 -0
- package/dist/source/{template-write.d.ts → template-seed.d.ts} +19 -20
- package/dist/source/template-seed.js +80 -0
- package/dist/standalone/agent-directory.d.ts +4 -0
- package/dist/standalone/agent-directory.js +4 -0
- package/dist/standalone/agents.d.ts +227 -0
- package/dist/standalone/agents.js +834 -0
- package/dist/standalone/app-builds-binding.d.ts +36 -0
- package/dist/standalone/app-builds-binding.js +119 -0
- package/dist/standalone/app-set.d.ts +91 -0
- package/dist/standalone/app-set.js +395 -0
- package/dist/standalone/app-source.d.ts +41 -0
- package/dist/standalone/app-source.js +59 -0
- package/dist/standalone/builds.d.ts +18 -0
- package/dist/standalone/builds.js +101 -0
- package/dist/standalone/computer-egress.d.ts +37 -0
- package/dist/standalone/computer-egress.js +111 -0
- package/dist/standalone/computer.d.ts +122 -0
- package/dist/standalone/computer.js +714 -0
- package/dist/standalone/install-apps-cli.d.ts +2 -0
- package/dist/standalone/install-apps-cli.js +126 -0
- package/dist/standalone/instance.d.ts +35 -0
- package/dist/standalone/instance.js +74 -0
- package/dist/standalone/media.d.ts +76 -0
- package/dist/standalone/media.js +227 -0
- package/dist/standalone/preview.d.ts +51 -0
- package/dist/standalone/preview.js +167 -0
- package/dist/standalone/registry.d.ts +102 -0
- package/dist/standalone/registry.js +193 -0
- package/dist/standalone/tenancy.d.ts +42 -0
- package/dist/standalone/tenancy.js +241 -0
- package/dist/standalone/worker.d.ts +55 -0
- package/dist/standalone/worker.js +147 -0
- package/dist/standalone/workflow-authority-readers.d.ts +16 -0
- package/dist/standalone/workflow-authority-readers.js +35 -0
- package/dist/standalone/workflow-binding.d.ts +216 -0
- package/dist/standalone/workflow-binding.js +217 -0
- package/dist/standalone/workflow-events.d.ts +46 -0
- package/dist/standalone/workflow-events.js +39 -0
- package/dist/standalone/workflow-git-capture.d.ts +7 -0
- package/dist/standalone/workflow-git-capture.js +62 -0
- package/dist/standalone/workflow-host.d.ts +955 -0
- package/dist/standalone/workflow-host.js +778 -0
- package/dist/standalone/workflow-node-capture.d.ts +13 -0
- package/dist/standalone/workflow-node-capture.js +72 -0
- package/dist/standalone/workflow-root-authority.d.ts +27 -0
- package/dist/standalone/workflow-root-authority.js +87 -0
- package/dist/standalone/workflow-schedules.d.ts +52 -0
- package/dist/standalone/workflow-schedules.js +40 -0
- package/dist/standalone/workflow-trigger-host.d.ts +1716 -0
- package/dist/standalone/workflow-trigger-host.js +52 -0
- package/dist/standalone/workflows.d.ts +713 -0
- package/dist/standalone/workflows.js +554 -0
- package/dist/tenancy/auth0.d.ts +64 -0
- package/dist/tenancy/auth0.js +198 -0
- package/dist/tenancy/cloudflare/rate-limit.d.ts +8 -0
- package/dist/tenancy/cloudflare/rate-limit.js +19 -0
- package/dist/tenancy/cloudflare/registry.d.ts +50 -0
- package/dist/tenancy/cloudflare/registry.js +301 -0
- package/dist/tenancy/cloudflare.d.ts +2 -0
- package/dist/tenancy/cloudflare.js +2 -0
- package/dist/tenancy/cold-directory.d.ts +13 -0
- package/dist/tenancy/cold-directory.js +43 -0
- package/dist/tenancy/contracts.d.ts +195 -0
- package/dist/tenancy/contracts.js +1 -0
- package/dist/tenancy/index.d.ts +19 -0
- package/dist/tenancy/index.js +156 -0
- package/dist/tenancy/owner-key.d.ts +26 -0
- package/dist/tenancy/owner-key.js +63 -0
- package/dist/tenancy/oyster.d.ts +4 -0
- package/dist/tenancy/oyster.js +25 -0
- package/dist/tenancy/registry-record.d.ts +7 -0
- package/dist/tenancy/registry-record.js +44 -0
- package/dist/tenancy/registry.d.ts +27 -0
- package/dist/tenancy/registry.js +138 -0
- package/dist/tenancy/resolve.d.ts +7 -0
- package/dist/tenancy/resolve.js +64 -0
- package/dist/workers-git.js +1 -4
- package/dist/workers.d.ts +2 -2
- package/dist/workers.js +3 -1
- package/docs/api/build-contract.md +829 -32
- package/docs/api/build.md +253 -17
- package/docs/api/modules.md +40 -32
- package/docs/api/pack.md +6 -3
- package/docs/api/shell/app-directory.md +267 -0
- package/docs/api/shell/shell-module.md +64 -3
- package/docs/api/shell/workbench-directory.md +119 -0
- package/docs/api/shell/workbench.md +19 -0
- package/docs/api/shell.md +127 -0
- package/docs/api/tenancy/auth0.md +110 -0
- package/docs/api/tenancy/cloudflare.md +221 -0
- package/docs/api/tenancy/cold-directory.md +46 -0
- package/docs/api/tenancy/owner-key.md +63 -0
- package/docs/api/tenancy/registry.md +77 -0
- package/docs/api/tenancy/resolve.md +69 -0
- package/docs/api/tenancy.md +430 -0
- package/docs/api/workers-git.md +140 -21
- package/docs/api/workers.md +15 -2
- package/docs/site/agent-quickstart.mdx +52 -55
- package/examples/standalone/worker.ts +7 -14
- package/examples/standalone/wrangler.jsonc +67 -6
- package/package.json +78 -25
- package/dist/build/r2-artifact-writer.d.ts +0 -31
- package/dist/build/r2-artifact-writer.js +0 -179
- package/dist/source/template-write.js +0 -344
- package/docs/media/architecture.mmd +0 -32
- package/docs/media/architecture.png +0 -0
- package/docs/media/bonsai-network.png +0 -0
- package/docs/media/bonsai-repository.png +0 -0
- package/examples/git/worker.ts +0 -39
- package/examples/git/wrangler.jsonc +0 -32
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { OysterTenancy } from "../tenant.js";
|
|
2
|
+
import type { TenantId, TenantPrincipal, TenantScope } from "../tenant.js";
|
|
3
|
+
import type { Hono, MiddlewareHandler } from "hono";
|
|
4
|
+
/** An external identity; the registry owns its canonical member mapping. */
|
|
5
|
+
export type Identity = Readonly<{
|
|
6
|
+
/** Verified issuer/provider namespace, kept separate from the subject. */
|
|
7
|
+
provider: string;
|
|
8
|
+
/** Stable verified subject within that provider. */
|
|
9
|
+
subject: string;
|
|
10
|
+
/** Optional display metadata, never a membership lookup key. */
|
|
11
|
+
email?: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** One provisioned tenant and its registry-owned canonical public origin. */
|
|
14
|
+
export type Tenant = Readonly<{
|
|
15
|
+
/** Host-selected tenant identity used by scope naming. */
|
|
16
|
+
id: TenantId;
|
|
17
|
+
/** Provisioned label used by hostname and path resolution. */
|
|
18
|
+
handle: string;
|
|
19
|
+
/** Canonical HTTPS origin matching one declared domain. */
|
|
20
|
+
origin: string;
|
|
21
|
+
/** Suspended tenants refuse admission; deleting tenants do not resolve. */
|
|
22
|
+
status: "active" | "suspended" | "deleting";
|
|
23
|
+
/** Stored canonical identities of this tenant's owners. */
|
|
24
|
+
owners: readonly string[];
|
|
25
|
+
/** Registered canonical URL hosts, including any configured port. */
|
|
26
|
+
domains: readonly string[];
|
|
27
|
+
}>;
|
|
28
|
+
/** A live membership, carrying a stored host-owned member identity. */
|
|
29
|
+
export type Membership = Readonly<{
|
|
30
|
+
/** Canonical member ID minted or supplied by the registry owner. */
|
|
31
|
+
memberId: string;
|
|
32
|
+
/** Authority granted by this tenant's membership record. */
|
|
33
|
+
role: "owner" | "member";
|
|
34
|
+
/** Positive safe revision owned by the registry; changes never reuse a deleted membership's revision. */
|
|
35
|
+
membershipVersion: number;
|
|
36
|
+
}>;
|
|
37
|
+
/** Reads existing authority; this port cannot mutate tenants or members. */
|
|
38
|
+
export type ReadonlyTenantRegistry = Readonly<{
|
|
39
|
+
/** Read a provisioned record, or null after removal. */
|
|
40
|
+
get(id: TenantId): Promise<Tenant | null>;
|
|
41
|
+
/** Resolve a provisioned handle; unknown handles never fall back. */
|
|
42
|
+
byHandle(handle: string): Promise<Tenant | null>;
|
|
43
|
+
/** Optional index for custom-domain resolution. */
|
|
44
|
+
byDomain?(host: string): Promise<Tenant | null>;
|
|
45
|
+
/** Read live admission status, or null for a removed tenant. */
|
|
46
|
+
status(id: TenantId): Promise<Tenant["status"] | null>;
|
|
47
|
+
/** Look up the separate provider/subject pair and its canonical membership. */
|
|
48
|
+
membershipOf(id: TenantId, identity: Identity): Promise<Membership | null>;
|
|
49
|
+
/** Read the stored membership for a canonical member ID; null means absent. */
|
|
50
|
+
memberById(id: TenantId, memberId: string): Promise<Membership | null>;
|
|
51
|
+
}>;
|
|
52
|
+
/** A single create transition records tenant and owner membership together. */
|
|
53
|
+
export type TenantCreateInput = Readonly<{
|
|
54
|
+
/** New provisioned tenant handle. */
|
|
55
|
+
handle: string;
|
|
56
|
+
/** HTTPS origin validated against domains before storage. */
|
|
57
|
+
origin: string;
|
|
58
|
+
/** Canonical hosts owned by this tenant. */
|
|
59
|
+
domains: readonly string[];
|
|
60
|
+
/** External identity whose canonical owner membership is recorded atomically. */
|
|
61
|
+
owner: Identity;
|
|
62
|
+
}>;
|
|
63
|
+
/** Mutable registry owner; remove is called only after teardown succeeds. */
|
|
64
|
+
export type ManagedTenantRegistry = ReadonlyTenantRegistry & Readonly<{
|
|
65
|
+
/** Atomically store a tenant and mint/reuse its canonical owner membership. */
|
|
66
|
+
create(input: TenantCreateInput): Promise<Tenant>;
|
|
67
|
+
/** Change admission status without discarding the tenant's scope record. */
|
|
68
|
+
setStatus(id: TenantId, status: Tenant["status"]): Promise<void>;
|
|
69
|
+
/** Remove the record and its memberships after successful teardown. */
|
|
70
|
+
delete(id: TenantId): Promise<void>;
|
|
71
|
+
}>;
|
|
72
|
+
/** Request URL resolution, with an explicit origin/path distinction. */
|
|
73
|
+
export type TenantResolverAdapter<Kind extends "origin" | "path" = "origin" | "path"> = Readonly<{
|
|
74
|
+
/** Origin resolvers can host Oyster; path resolvers serve APIs only. */
|
|
75
|
+
kind: Kind;
|
|
76
|
+
/** Resolve only from the parsed request URL and provisioned registry. */
|
|
77
|
+
resolve(url: URL, registry: ReadonlyTenantRegistry): Promise<TenantId | null>;
|
|
78
|
+
}>;
|
|
79
|
+
/** Adapter output: pass only when the presented credential is not this adapter's. */
|
|
80
|
+
export type AuthResult = Readonly<{
|
|
81
|
+
/** A verified external identity requiring registry membership admission. */
|
|
82
|
+
kind: "identity";
|
|
83
|
+
/** Verified provider and subject, not a caller-selected member ID. */
|
|
84
|
+
identity: Identity;
|
|
85
|
+
}> | Readonly<{
|
|
86
|
+
/** A canonical principal already attested for the selected tenant. */
|
|
87
|
+
kind: "principal";
|
|
88
|
+
/** Host-attested actor, with a role only for a member. */
|
|
89
|
+
principal: TenantPrincipal;
|
|
90
|
+
}> | Readonly<{
|
|
91
|
+
/** This adapter does not recognize the presented credential. */
|
|
92
|
+
kind: "pass";
|
|
93
|
+
}> | Readonly<{
|
|
94
|
+
/** Recognized but invalid credential; stop the chain. */
|
|
95
|
+
kind: "refused";
|
|
96
|
+
/** Authentication or authorization refusal. */
|
|
97
|
+
status: 401 | 403;
|
|
98
|
+
}>;
|
|
99
|
+
/** Authentication proves an identity, or attests a principal for this tenant. */
|
|
100
|
+
export type AuthAdapter = Readonly<{
|
|
101
|
+
/** Stable adapter name for host composition. */
|
|
102
|
+
id: string;
|
|
103
|
+
/** Optional relative login/callback routes, mounted explicitly by the host. */
|
|
104
|
+
routes?: Hono;
|
|
105
|
+
/** Verify the credential for the host-selected tenant; pass advances the chain. */
|
|
106
|
+
authenticate(request: Request, tenant: Tenant): Promise<AuthResult>;
|
|
107
|
+
}>;
|
|
108
|
+
/** Abuse-only admission policy; it does not meter or bill usage. */
|
|
109
|
+
export type TenancyPolicy = (context: Readonly<{
|
|
110
|
+
/** Provisioned tenant selected before authentication. */
|
|
111
|
+
tenant: Tenant;
|
|
112
|
+
/** Admitted canonical actor and member role, if applicable. */
|
|
113
|
+
principal: TenantPrincipal;
|
|
114
|
+
/** Original request, for trusted host abuse-policy inputs. */
|
|
115
|
+
request: Request;
|
|
116
|
+
}>) => Promise<Readonly<{
|
|
117
|
+
/** Continue admission. */
|
|
118
|
+
kind: "allow";
|
|
119
|
+
}> | Readonly<{
|
|
120
|
+
/** Refuse for abuse limiting. */
|
|
121
|
+
kind: "refuse";
|
|
122
|
+
/** The policy's rate-limiting HTTP status. */
|
|
123
|
+
status: 429;
|
|
124
|
+
/** Retry delay in seconds for the HTTP Retry-After header. */
|
|
125
|
+
retryAfter: number;
|
|
126
|
+
}>>;
|
|
127
|
+
/** Lifecycle callbacks run after create and before physical deletion. */
|
|
128
|
+
export type TenancyHooks = Readonly<{
|
|
129
|
+
/** Notification after the atomic registry create has committed. */
|
|
130
|
+
"tenant.created"?: (context: Readonly<{
|
|
131
|
+
/** Newly committed tenant, including its canonical owner membership. */
|
|
132
|
+
tenant: Tenant;
|
|
133
|
+
}>) => Promise<void>;
|
|
134
|
+
/** Idempotent cleanup before removal; failures retain the deleting record. */
|
|
135
|
+
"tenant.deleting"?: (context: Readonly<{
|
|
136
|
+
/** Registry-owned record marked deleting. */
|
|
137
|
+
tenant: Tenant;
|
|
138
|
+
/** Scope built from the record's canonical origin, never a request. */
|
|
139
|
+
scope: TenantScope;
|
|
140
|
+
}>) => Promise<void>;
|
|
141
|
+
}>;
|
|
142
|
+
/** Typed result of tenant authentication and abuse-policy admission. */
|
|
143
|
+
export type TenancyAdmission = Readonly<{
|
|
144
|
+
/** Successful request admission. */
|
|
145
|
+
kind: "admitted";
|
|
146
|
+
/** Provisioned tenant whose live status permits admission. */
|
|
147
|
+
tenant: Tenant;
|
|
148
|
+
/** Canonical actor admitted within that tenant. */
|
|
149
|
+
principal: TenantPrincipal;
|
|
150
|
+
}> | Readonly<{
|
|
151
|
+
/** No principal or tenant is exposed on refusal. */
|
|
152
|
+
kind: "refused";
|
|
153
|
+
/** Safe authentication, authorization, absence or abuse outcome. */
|
|
154
|
+
status: 401 | 403 | 404 | 429;
|
|
155
|
+
/** Optional retry delay in seconds for an abuse refusal. */
|
|
156
|
+
retryAfter?: number;
|
|
157
|
+
}>;
|
|
158
|
+
/** Typed Hono context fields populated only after admission. */
|
|
159
|
+
export type TenancyVariables = {
|
|
160
|
+
/** The admitted provisioned tenant. */
|
|
161
|
+
tenant: Tenant;
|
|
162
|
+
/** The admitted canonical principal. */
|
|
163
|
+
principal: TenantPrincipal;
|
|
164
|
+
};
|
|
165
|
+
/** Read-only host API; no create/delete methods are exposed. */
|
|
166
|
+
export type ReadonlyTenancy<Kind extends "origin" | "path" = "origin" | "path"> = Readonly<{
|
|
167
|
+
/** Resolve the provisioned tenant from the request URL. */
|
|
168
|
+
resolve(request: Request): Promise<Tenant | null>;
|
|
169
|
+
/** Re-resolve the request and authenticate within the selected tenant. */
|
|
170
|
+
authenticate(request: Request, tenant: Tenant): Promise<TenancyAdmission>;
|
|
171
|
+
/** Read canonical membership without deriving IDs from external identity data. */
|
|
172
|
+
member(tenantId: TenantId, identity: Identity): Promise<TenantPrincipal | null>;
|
|
173
|
+
/** Resolve current active-tenant membership by stored member ID, without credentials. */
|
|
174
|
+
memberById(tenantId: TenantId, memberId: string): Promise<(TenantPrincipal & Readonly<{
|
|
175
|
+
membershipVersion: number;
|
|
176
|
+
}>) | null>;
|
|
177
|
+
/** Admit requests and populate Hono context before reaching protected handlers. */
|
|
178
|
+
middleware(options?: Readonly<{
|
|
179
|
+
/** Require a member role; owners satisfy member, keys/Agents satisfy neither. */
|
|
180
|
+
role?: "owner" | "member";
|
|
181
|
+
}>): MiddlewareHandler<{
|
|
182
|
+
/** Context fields supplied only after successful tenant admission. */
|
|
183
|
+
Variables: TenancyVariables;
|
|
184
|
+
}>;
|
|
185
|
+
}> & (Kind extends "origin" ? Readonly<{
|
|
186
|
+
/** Bind canonical tenant scope and authentication to the Oyster host port. */
|
|
187
|
+
oyster(): OysterTenancy;
|
|
188
|
+
}> : Record<never, never>);
|
|
189
|
+
/** Managed hosts can provision tenants and run teardown through the kit. */
|
|
190
|
+
export type ManagedTenancy<Kind extends "origin" | "path" = "origin" | "path"> = ReadonlyTenancy<Kind> & Readonly<{
|
|
191
|
+
/** Delegate atomic provisioning, then invoke the created hook. */
|
|
192
|
+
create(input: TenantCreateInput): Promise<Tenant>;
|
|
193
|
+
/** Hold deleting status until all configured cleanup succeeds. */
|
|
194
|
+
delete(id: TenantId): Promise<void>;
|
|
195
|
+
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AuthAdapter, ManagedTenantRegistry, ManagedTenancy, ReadonlyTenantRegistry, ReadonlyTenancy, TenancyHooks, TenancyPolicy, TenantResolverAdapter } from "./contracts.js";
|
|
2
|
+
/** Configuration composes resolution, live registry authority and auth adapters. */
|
|
3
|
+
export type TenancyConfig<Registry extends ReadonlyTenantRegistry = ReadonlyTenantRegistry, Kind extends "origin" | "path" = "origin" | "path"> = Readonly<{
|
|
4
|
+
/** Select tenants only from the parsed request URL. */
|
|
5
|
+
resolve: TenantResolverAdapter<Kind>;
|
|
6
|
+
/** The tenant and canonical membership authority. */
|
|
7
|
+
registry: Registry;
|
|
8
|
+
/** Ordered authentication adapters; pass alone advances the chain. */
|
|
9
|
+
auth: readonly AuthAdapter[];
|
|
10
|
+
/** Optional abuse-only policy applied after authentication. */
|
|
11
|
+
policy?: TenancyPolicy;
|
|
12
|
+
/** Lifecycle notifications for managed registry hosts. */
|
|
13
|
+
on?: TenancyHooks;
|
|
14
|
+
}>;
|
|
15
|
+
/** Managed registries expose provisioning and cleanup; readonly registries do not. */
|
|
16
|
+
export declare function createTenancy<Kind extends "origin" | "path">(config: TenancyConfig<ManagedTenantRegistry, Kind>): ManagedTenancy<Kind>;
|
|
17
|
+
/** Resolve and authenticate requests without exposing registry mutations. */
|
|
18
|
+
export declare function createTenancy<Kind extends "origin" | "path">(config: TenancyConfig<ReadonlyTenantRegistry, Kind>): ReadonlyTenancy<Kind>;
|
|
19
|
+
export type { AuthAdapter, AuthResult, Identity, ManagedTenantRegistry, ManagedTenancy, Membership, ReadonlyTenantRegistry, ReadonlyTenancy, Tenant, TenantCreateInput, TenancyAdmission, TenancyHooks, TenancyPolicy, TenancyVariables, TenantResolverAdapter, } from "./contracts.js";
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { createOysterTenancy } from "./oyster.js";
|
|
2
|
+
import { tenantScope } from "../tenant.js";
|
|
3
|
+
function managed(registry) {
|
|
4
|
+
return ("create" in registry &&
|
|
5
|
+
typeof registry.create === "function" &&
|
|
6
|
+
"setStatus" in registry &&
|
|
7
|
+
typeof registry.setStatus === "function" &&
|
|
8
|
+
"delete" in registry &&
|
|
9
|
+
typeof registry.delete === "function");
|
|
10
|
+
}
|
|
11
|
+
export function createTenancy(config) {
|
|
12
|
+
const registry = config.registry;
|
|
13
|
+
const adapters = [...config.auth];
|
|
14
|
+
const policy = config.policy;
|
|
15
|
+
const resolve = async (request) => {
|
|
16
|
+
const url = new URL(request.url);
|
|
17
|
+
const id = await config.resolve.resolve(url, registry);
|
|
18
|
+
if (id === null)
|
|
19
|
+
return null;
|
|
20
|
+
const tenant = await registry.get(id);
|
|
21
|
+
if (tenant === null || tenant.status === "deleting")
|
|
22
|
+
return null;
|
|
23
|
+
if (config.resolve.kind === "origin" &&
|
|
24
|
+
(url.protocol !== "https:" || !tenant.domains.includes(url.host)))
|
|
25
|
+
return null;
|
|
26
|
+
return tenant;
|
|
27
|
+
};
|
|
28
|
+
const member = async (tenantId, identity) => {
|
|
29
|
+
const status = await registry.status(tenantId);
|
|
30
|
+
if (status === null || status === "deleting")
|
|
31
|
+
return null;
|
|
32
|
+
const membership = await registry.membershipOf(tenantId, identity);
|
|
33
|
+
return membership === null
|
|
34
|
+
? null
|
|
35
|
+
: {
|
|
36
|
+
actor: { kind: "member", memberId: membership.memberId },
|
|
37
|
+
role: membership.role,
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
const memberById = async (tenantId, memberId) => {
|
|
41
|
+
if ((await registry.status(tenantId)) !== "active")
|
|
42
|
+
return null;
|
|
43
|
+
const membership = await registry.memberById(tenantId, memberId);
|
|
44
|
+
if (membership?.memberId !== memberId ||
|
|
45
|
+
(await registry.status(tenantId)) !== "active")
|
|
46
|
+
return null;
|
|
47
|
+
return {
|
|
48
|
+
actor: { kind: "member", memberId: membership.memberId },
|
|
49
|
+
role: membership.role,
|
|
50
|
+
membershipVersion: membership.membershipVersion,
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
const admit = async (request, tenant) => {
|
|
54
|
+
if (tenant.status !== "active")
|
|
55
|
+
return { kind: "refused", status: 403 };
|
|
56
|
+
for (const adapter of adapters) {
|
|
57
|
+
const result = await adapter.authenticate(request, tenant);
|
|
58
|
+
if (result.kind === "pass")
|
|
59
|
+
continue;
|
|
60
|
+
if (result.kind === "refused")
|
|
61
|
+
return result;
|
|
62
|
+
const principal = result.kind === "identity"
|
|
63
|
+
? await member(tenant.id, result.identity)
|
|
64
|
+
: result.principal;
|
|
65
|
+
if (principal === null)
|
|
66
|
+
return { kind: "refused", status: 403 };
|
|
67
|
+
const outcome = await policy?.({ tenant, principal, request });
|
|
68
|
+
if (outcome?.kind === "refuse")
|
|
69
|
+
return { kind: "refused", status: 429, retryAfter: outcome.retryAfter };
|
|
70
|
+
const status = await registry.status(tenant.id);
|
|
71
|
+
if (status === null || status === "deleting")
|
|
72
|
+
return { kind: "refused", status: 404 };
|
|
73
|
+
if (status !== "active")
|
|
74
|
+
return { kind: "refused", status: 403 };
|
|
75
|
+
return { kind: "admitted", tenant, principal };
|
|
76
|
+
}
|
|
77
|
+
return { kind: "refused", status: 401 };
|
|
78
|
+
};
|
|
79
|
+
const authenticate = async (request, selected) => {
|
|
80
|
+
const tenant = await resolve(request);
|
|
81
|
+
return tenant?.id === selected.id
|
|
82
|
+
? admit(request, tenant)
|
|
83
|
+
: { kind: "refused", status: 404 };
|
|
84
|
+
};
|
|
85
|
+
const middleware = (options = {}) => async (context, next) => {
|
|
86
|
+
const request = context.req.raw;
|
|
87
|
+
const tenant = await resolve(request);
|
|
88
|
+
const admission = tenant === null
|
|
89
|
+
? { kind: "refused", status: 404 }
|
|
90
|
+
: await admit(request, tenant);
|
|
91
|
+
if (admission.kind === "refused") {
|
|
92
|
+
if (admission.retryAfter !== undefined)
|
|
93
|
+
context.header("Retry-After", String(admission.retryAfter));
|
|
94
|
+
const messages = {
|
|
95
|
+
401: "unauthorized",
|
|
96
|
+
403: "forbidden",
|
|
97
|
+
404: "not_found",
|
|
98
|
+
429: "rate_limited",
|
|
99
|
+
};
|
|
100
|
+
return context.json({ error: messages[admission.status] }, admission.status);
|
|
101
|
+
}
|
|
102
|
+
const { principal } = admission;
|
|
103
|
+
if (options.role !== undefined &&
|
|
104
|
+
(principal.actor.kind !== "member" ||
|
|
105
|
+
!("role" in principal) ||
|
|
106
|
+
(options.role === "owner" && principal.role !== "owner")))
|
|
107
|
+
return context.json({ error: "forbidden" }, 403);
|
|
108
|
+
context.set("tenant", admission.tenant);
|
|
109
|
+
context.set("principal", principal);
|
|
110
|
+
return next();
|
|
111
|
+
};
|
|
112
|
+
const readonly = {
|
|
113
|
+
resolve,
|
|
114
|
+
member,
|
|
115
|
+
memberById,
|
|
116
|
+
authenticate,
|
|
117
|
+
middleware,
|
|
118
|
+
};
|
|
119
|
+
const api = config.resolve.kind === "origin"
|
|
120
|
+
? { ...readonly, oyster: () => createOysterTenancy(readonly) }
|
|
121
|
+
: readonly;
|
|
122
|
+
if (!managed(registry))
|
|
123
|
+
return api;
|
|
124
|
+
const hooks = { ...config.on };
|
|
125
|
+
const deleting = new Map();
|
|
126
|
+
return {
|
|
127
|
+
...api,
|
|
128
|
+
async create(input) {
|
|
129
|
+
const tenant = await registry.create(input);
|
|
130
|
+
await hooks["tenant.created"]?.({ tenant });
|
|
131
|
+
return tenant;
|
|
132
|
+
},
|
|
133
|
+
delete(id) {
|
|
134
|
+
const existing = deleting.get(id);
|
|
135
|
+
if (existing !== undefined)
|
|
136
|
+
return existing;
|
|
137
|
+
const pending = Promise.resolve()
|
|
138
|
+
.then(async () => {
|
|
139
|
+
const tenant = await registry.get(id);
|
|
140
|
+
if (tenant === null)
|
|
141
|
+
return;
|
|
142
|
+
await registry.setStatus(id, "deleting");
|
|
143
|
+
await hooks["tenant.deleting"]?.({
|
|
144
|
+
tenant: { ...tenant, status: "deleting" },
|
|
145
|
+
scope: tenantScope(tenant.id, tenant.origin),
|
|
146
|
+
});
|
|
147
|
+
await registry.delete(id);
|
|
148
|
+
})
|
|
149
|
+
.finally(() => {
|
|
150
|
+
deleting.delete(id);
|
|
151
|
+
});
|
|
152
|
+
deleting.set(id, pending);
|
|
153
|
+
return pending;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TenantId } from "../tenant.js";
|
|
2
|
+
import type { AuthAdapter, ReadonlyTenantRegistry, ReadonlyTenancy } from "./contracts.js";
|
|
3
|
+
/** Stored owner verification material for one selected tenant. */
|
|
4
|
+
export type OwnerKeyRecord = Readonly<{
|
|
5
|
+
/** The host-held owner secret, verified only by Oyster's auth helper. */
|
|
6
|
+
key: string;
|
|
7
|
+
/** Explicit canonical member identity stored by the host. */
|
|
8
|
+
memberId: string;
|
|
9
|
+
}>;
|
|
10
|
+
/** Live owner authority and host-held verification material. */
|
|
11
|
+
export type OwnerKeyOptions = Readonly<{
|
|
12
|
+
/** Current tenant owner authority, re-read after credential verification. */
|
|
13
|
+
registry: Pick<ReadonlyTenantRegistry, "get">;
|
|
14
|
+
/** Host-owned tenant key lookup; never Oyster's named Agent-key store. */
|
|
15
|
+
keys: Readonly<{
|
|
16
|
+
/** Current verification material, or null when no owner key is configured. */
|
|
17
|
+
get(tenantId: TenantId): Promise<OwnerKeyRecord | null>;
|
|
18
|
+
}>;
|
|
19
|
+
/** Optional relative POST /login route, explicitly mounted by the host. */
|
|
20
|
+
login?: Readonly<{
|
|
21
|
+
/** Resolve the tenant through the kit, not from form/body data. */
|
|
22
|
+
resolveTenant: ReadonlyTenancy["resolve"];
|
|
23
|
+
}>;
|
|
24
|
+
}>;
|
|
25
|
+
/** Reuse Oyster verification and admit only a current, explicitly stored owner. */
|
|
26
|
+
export declare function ownerKey(options: OwnerKeyOptions): AuthAdapter;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ownerKeyAuth, ownerKeyCookieName } from "../auth.js";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { parse } from "hono/utils/cookie";
|
|
4
|
+
/** Reuse Oyster verification and admit only a current, explicitly stored owner. */
|
|
5
|
+
export function ownerKey(options) {
|
|
6
|
+
const currentOwner = async (tenant, memberId) => {
|
|
7
|
+
const current = await options.registry.get(tenant.id);
|
|
8
|
+
return (current !== null &&
|
|
9
|
+
current.status === "active" &&
|
|
10
|
+
current.owners.includes(memberId));
|
|
11
|
+
};
|
|
12
|
+
const routes = new Hono();
|
|
13
|
+
const login = options.login;
|
|
14
|
+
if (login !== undefined)
|
|
15
|
+
routes.post("/login", async (context) => {
|
|
16
|
+
const tenant = await login.resolveTenant(context.req.raw);
|
|
17
|
+
if (tenant === null)
|
|
18
|
+
return context.json({ error: "not_found" }, 404);
|
|
19
|
+
const record = await options.keys.get(tenant.id);
|
|
20
|
+
if (record === null)
|
|
21
|
+
return context.json({ error: "unauthorized" }, 401);
|
|
22
|
+
const helper = ownerKeyAuth(record.key, record.memberId);
|
|
23
|
+
if (helper.login === undefined)
|
|
24
|
+
throw new Error("Oyster owner authentication has no login handler");
|
|
25
|
+
const response = await helper.login(context.req.raw);
|
|
26
|
+
if (response.status !== 303)
|
|
27
|
+
return response;
|
|
28
|
+
if (!(await currentOwner(tenant, record.memberId)))
|
|
29
|
+
return context.json({ error: "forbidden" }, 403);
|
|
30
|
+
response.headers.set("Cache-Control", "no-store");
|
|
31
|
+
return response;
|
|
32
|
+
});
|
|
33
|
+
const adapter = {
|
|
34
|
+
id: "owner-key",
|
|
35
|
+
async authenticate(request, tenant) {
|
|
36
|
+
const cookies = parse(request.headers.get("Cookie") ?? "");
|
|
37
|
+
const recognizedCookie = Object.hasOwn(cookies, ownerKeyCookieName);
|
|
38
|
+
const bearer = request.headers.get("Authorization")?.startsWith("Bearer ") === true;
|
|
39
|
+
if (!recognizedCookie && !bearer)
|
|
40
|
+
return { kind: "pass" };
|
|
41
|
+
const record = await options.keys.get(tenant.id);
|
|
42
|
+
if (record === null)
|
|
43
|
+
return recognizedCookie
|
|
44
|
+
? { kind: "refused", status: 401 }
|
|
45
|
+
: { kind: "pass" };
|
|
46
|
+
const result = await ownerKeyAuth(record.key, record.memberId).authenticate(request);
|
|
47
|
+
if (result.kind === "refused")
|
|
48
|
+
return recognizedCookie
|
|
49
|
+
? { kind: "refused", status: 401 }
|
|
50
|
+
: { kind: "pass" };
|
|
51
|
+
if (!(await currentOwner(tenant, record.memberId)))
|
|
52
|
+
return { kind: "refused", status: 403 };
|
|
53
|
+
return {
|
|
54
|
+
kind: "principal",
|
|
55
|
+
principal: {
|
|
56
|
+
actor: { kind: "member", memberId: record.memberId },
|
|
57
|
+
role: "owner",
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
return login === undefined ? adapter : { ...adapter, routes };
|
|
63
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type OysterTenancy } from "../tenant.js";
|
|
2
|
+
import type { ReadonlyTenancy } from "./contracts.js";
|
|
3
|
+
/** Bind the kit's live registry/auth admission to the canonical Oyster port. */
|
|
4
|
+
export declare function createOysterTenancy(tenancy: Pick<ReadonlyTenancy, "resolve" | "authenticate" | "memberById">): OysterTenancy;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { tenantScope } from "../tenant.js";
|
|
2
|
+
/** Bind the kit's live registry/auth admission to the canonical Oyster port. */
|
|
3
|
+
export function createOysterTenancy(tenancy) {
|
|
4
|
+
return {
|
|
5
|
+
async resolve(request) {
|
|
6
|
+
const tenant = await tenancy.resolve(request);
|
|
7
|
+
if (tenant === null)
|
|
8
|
+
return null;
|
|
9
|
+
return {
|
|
10
|
+
scope: tenantScope(tenant.id, tenant.origin),
|
|
11
|
+
auth: {
|
|
12
|
+
resolve: (actor) => actor.kind === "member"
|
|
13
|
+
? tenancy.memberById(tenant.id, actor.memberId)
|
|
14
|
+
: Promise.resolve(null),
|
|
15
|
+
authenticate: async (incoming) => {
|
|
16
|
+
const result = await tenancy.authenticate(incoming, tenant);
|
|
17
|
+
return result.kind === "admitted"
|
|
18
|
+
? { kind: "admitted", principal: result.principal }
|
|
19
|
+
: result;
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import type { Tenant } from "./contracts.js";
|
|
3
|
+
export declare function checkedOrigin(origin: string, domains: readonly string[]): string;
|
|
4
|
+
export declare function immutable(tenant: Tenant): Tenant;
|
|
5
|
+
export declare function createMemberId(): string;
|
|
6
|
+
/** Stored membership authority revisions are positive safe integers. */
|
|
7
|
+
export declare const membershipVersionSchema: Schema.Number;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import { defineIdFamily } from "@fungi.computer/cairn/id";
|
|
3
|
+
const identityIds = defineIdFamily({
|
|
4
|
+
family: "tenancy-member",
|
|
5
|
+
version: 1,
|
|
6
|
+
kinds: { member: { prefix: "tenant_member" } },
|
|
7
|
+
});
|
|
8
|
+
export function checkedOrigin(origin, domains) {
|
|
9
|
+
const url = new URL(origin);
|
|
10
|
+
if (url.protocol !== "https:" ||
|
|
11
|
+
url.username ||
|
|
12
|
+
url.password ||
|
|
13
|
+
url.pathname !== "/" ||
|
|
14
|
+
url.search ||
|
|
15
|
+
url.hash ||
|
|
16
|
+
!domains.includes(url.host))
|
|
17
|
+
throw new TypeError("Tenant origin must be HTTPS and match a declared domain, without credentials or a path");
|
|
18
|
+
for (const domain of domains) {
|
|
19
|
+
const parsed = new URL(`https://${domain}`);
|
|
20
|
+
if (parsed.host !== domain ||
|
|
21
|
+
parsed.pathname !== "/" ||
|
|
22
|
+
parsed.search ||
|
|
23
|
+
parsed.hash ||
|
|
24
|
+
parsed.username ||
|
|
25
|
+
parsed.password)
|
|
26
|
+
throw new TypeError("Tenant domains must be canonical hostnames");
|
|
27
|
+
}
|
|
28
|
+
return url.origin;
|
|
29
|
+
}
|
|
30
|
+
export function immutable(tenant) {
|
|
31
|
+
return Object.freeze({
|
|
32
|
+
id: tenant.id,
|
|
33
|
+
handle: tenant.handle,
|
|
34
|
+
origin: tenant.origin,
|
|
35
|
+
status: tenant.status,
|
|
36
|
+
owners: Object.freeze([...tenant.owners]),
|
|
37
|
+
domains: Object.freeze([...tenant.domains]),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
export function createMemberId() {
|
|
41
|
+
return identityIds.member.generate();
|
|
42
|
+
}
|
|
43
|
+
/** Stored membership authority revisions are positive safe integers. */
|
|
44
|
+
export const membershipVersionSchema = Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type TenantId } from "../tenant.js";
|
|
2
|
+
import type { Identity, ManagedTenantRegistry, Membership, Tenant } from "./contracts.js";
|
|
3
|
+
/** Initial live membership records for a disposable in-memory registry. */
|
|
4
|
+
export type MemoryMembership = Readonly<{
|
|
5
|
+
/** Provisioned tenant whose membership is seeded. */
|
|
6
|
+
tenantId: TenantId;
|
|
7
|
+
/** External identity tuple mapped to the canonical member ID. */
|
|
8
|
+
identity: Identity;
|
|
9
|
+
/** Canonical member identity supplied by the host. */
|
|
10
|
+
memberId: string;
|
|
11
|
+
/** Live tenant role for this canonical member. */
|
|
12
|
+
role: Membership["role"];
|
|
13
|
+
}>;
|
|
14
|
+
/** Provisioned records and canonical member mappings owned by the host. */
|
|
15
|
+
export type MemoryRegistryOptions = Readonly<{
|
|
16
|
+
/** Initial validated provisioned records. */
|
|
17
|
+
tenants?: readonly Tenant[];
|
|
18
|
+
/** Initial identity-to-member mappings and roles. */
|
|
19
|
+
memberships?: readonly MemoryMembership[];
|
|
20
|
+
}>;
|
|
21
|
+
/** The memory adapter always supplies the optional custom-domain index. */
|
|
22
|
+
export type MemoryRegistry = ManagedTenantRegistry & Readonly<{
|
|
23
|
+
/** Look up a registered canonical host. */
|
|
24
|
+
byDomain(host: string): Promise<Tenant | null>;
|
|
25
|
+
}>;
|
|
26
|
+
/** In-memory tenant and member owner; create updates both atomically. */
|
|
27
|
+
export declare function memoryRegistry(options?: MemoryRegistryOptions): MemoryRegistry;
|