@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.
Files changed (242) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1082 -2
  3. package/dist/agents.d.ts +114 -0
  4. package/dist/agents.js +322 -0
  5. package/dist/app-builds.d.ts +151 -0
  6. package/dist/app-builds.js +355 -0
  7. package/dist/app-sandbox.d.ts +2 -0
  8. package/dist/app-sandbox.js +2 -0
  9. package/dist/app-sources.d.ts +46 -0
  10. package/dist/app-sources.js +335 -0
  11. package/dist/app-storage.d.ts +141 -0
  12. package/dist/app-storage.js +281 -0
  13. package/dist/apps/gate.d.ts +105 -0
  14. package/dist/apps/gate.js +273 -0
  15. package/dist/apps/index.d.ts +5 -0
  16. package/dist/apps/index.js +5 -0
  17. package/dist/apps/installation.d.ts +248 -0
  18. package/dist/apps/installation.js +307 -0
  19. package/dist/apps/operations.d.ts +8 -0
  20. package/dist/apps/operations.js +98 -0
  21. package/dist/apps/tools.d.ts +33 -0
  22. package/dist/apps/tools.js +17 -0
  23. package/dist/auth.d.ts +11 -0
  24. package/dist/auth.js +93 -0
  25. package/dist/backend-request.d.ts +3 -0
  26. package/dist/backend-request.js +53 -0
  27. package/dist/build/build-jobs.d.ts +128 -0
  28. package/dist/build/build-jobs.js +197 -0
  29. package/dist/build/build-step.d.ts +26 -0
  30. package/dist/build/build-step.js +20 -0
  31. package/dist/build/bundled-html.d.ts +7 -0
  32. package/dist/build/bundled-html.js +64 -0
  33. package/dist/build/definition-emission.d.ts +34 -0
  34. package/dist/build/definition-emission.js +201 -0
  35. package/dist/build/effect.d.ts +231 -0
  36. package/dist/build/effect.js +396 -0
  37. package/dist/build/git-environment.d.ts +6 -0
  38. package/dist/build/git-environment.js +48 -0
  39. package/dist/build/html-recipe.d.ts +20 -0
  40. package/dist/build/html-recipe.js +51 -0
  41. package/dist/build/local-build.d.ts +57 -0
  42. package/dist/build/local-build.js +1078 -0
  43. package/dist/build/r2-artifact-writer.d.ts +31 -0
  44. package/dist/build/r2-artifact-writer.js +179 -0
  45. package/dist/build/snapshot-package-store.d.ts +2 -0
  46. package/dist/build/snapshot-package-store.js +21 -0
  47. package/dist/build/snapshot-program-scripts.d.ts +6 -0
  48. package/dist/build/snapshot-program-scripts.js +2 -0
  49. package/dist/build/submission-contract.d.ts +384 -0
  50. package/dist/build/submission-contract.js +70 -0
  51. package/dist/build/workspace-build.d.ts +50 -0
  52. package/dist/build/workspace-build.js +156 -0
  53. package/dist/build-contract.d.ts +4 -0
  54. package/dist/build-contract.js +4 -0
  55. package/dist/build-jobs.d.ts +2 -0
  56. package/dist/build-jobs.js +2 -0
  57. package/dist/build.d.ts +11 -0
  58. package/dist/build.js +13 -0
  59. package/dist/capability-descriptor.d.ts +46 -0
  60. package/dist/capability-descriptor.js +51 -0
  61. package/dist/catalog-frame.d.ts +39 -0
  62. package/dist/catalog-frame.js +72 -0
  63. package/dist/cause-class.d.ts +19 -0
  64. package/dist/cause-class.js +54 -0
  65. package/dist/cloudflare.d.ts +123 -0
  66. package/dist/cloudflare.js +391 -0
  67. package/dist/computer-terminal.d.ts +38 -0
  68. package/dist/computer-terminal.js +410 -0
  69. package/dist/computer.d.ts +60 -0
  70. package/dist/computer.js +33 -0
  71. package/dist/event-recipients.d.ts +57 -0
  72. package/dist/event-recipients.js +144 -0
  73. package/dist/facet-invocation.d.ts +4 -0
  74. package/dist/facet-invocation.js +45 -0
  75. package/dist/files.d.ts +42 -0
  76. package/dist/files.js +123 -0
  77. package/dist/frame-binding-wire-script.d.ts +2 -0
  78. package/dist/frame-binding-wire-script.js +1 -0
  79. package/dist/frame-binding-wire.d.ts +28 -0
  80. package/dist/frame-binding-wire.js +54 -0
  81. package/dist/git-admission.d.ts +52 -0
  82. package/dist/git-admission.js +188 -0
  83. package/dist/git-auth.d.ts +11 -0
  84. package/dist/git-auth.js +27 -0
  85. package/dist/git-door.d.ts +35 -0
  86. package/dist/git-door.js +108 -0
  87. package/dist/git-journal.d.ts +31 -0
  88. package/dist/git-journal.js +188 -0
  89. package/dist/git.d.ts +31 -0
  90. package/dist/git.js +64 -0
  91. package/dist/guest-policy.d.ts +9 -0
  92. package/dist/guest-policy.js +13 -0
  93. package/dist/host.d.ts +244 -0
  94. package/dist/host.js +1043 -0
  95. package/dist/index.d.ts +166 -0
  96. package/dist/index.js +320 -0
  97. package/dist/installation-consent.d.ts +79 -0
  98. package/dist/installation-consent.js +82 -0
  99. package/dist/internal/background-origins.d.ts +3 -0
  100. package/dist/internal/background-origins.js +8 -0
  101. package/dist/internal/delivery.d.ts +12 -0
  102. package/dist/internal/delivery.js +167 -0
  103. package/dist/internal/host-gate.d.ts +14 -0
  104. package/dist/internal/host-gate.js +62 -0
  105. package/dist/internal/registry-installations.d.ts +6 -0
  106. package/dist/internal/registry-installations.js +12 -0
  107. package/dist/invocation.d.ts +251 -0
  108. package/dist/invocation.js +377 -0
  109. package/dist/keys.d.ts +48 -0
  110. package/dist/keys.js +52 -0
  111. package/dist/mcp-owner.d.ts +27 -0
  112. package/dist/mcp-owner.js +143 -0
  113. package/dist/mcp.d.ts +89 -0
  114. package/dist/mcp.js +429 -0
  115. package/dist/media.d.ts +23 -0
  116. package/dist/media.js +12 -0
  117. package/dist/pack.d.ts +76 -0
  118. package/dist/pack.js +134 -0
  119. package/dist/page/capabilities.d.ts +23 -0
  120. package/dist/page/capabilities.js +256 -0
  121. package/dist/page/computer-handles.d.ts +79 -0
  122. package/dist/page/computer-handles.js +44 -0
  123. package/dist/page/lifetime.d.ts +6 -0
  124. package/dist/page/lifetime.js +19 -0
  125. package/dist/page/mount.d.ts +163 -0
  126. package/dist/page/mount.js +390 -0
  127. package/dist/page/storage.d.ts +3 -0
  128. package/dist/page/storage.js +82 -0
  129. package/dist/page/terminal.d.ts +5 -0
  130. package/dist/page/terminal.js +7 -0
  131. package/dist/page-script.d.ts +14 -0
  132. package/dist/page-script.js +5 -0
  133. package/dist/r2-files.d.ts +3 -0
  134. package/dist/r2-files.js +79 -0
  135. package/dist/release.d.ts +72 -0
  136. package/dist/release.js +164 -0
  137. package/dist/root-data.d.ts +86 -0
  138. package/dist/root-data.js +69 -0
  139. package/dist/shell/app-directory.d.ts +537 -0
  140. package/dist/shell/app-directory.js +195 -0
  141. package/dist/shell/browser-actions.d.ts +7 -0
  142. package/dist/shell/browser-actions.js +56 -0
  143. package/dist/shell/command-host.d.ts +112 -0
  144. package/dist/shell/command-host.js +502 -0
  145. package/dist/shell/commands.d.ts +25 -0
  146. package/dist/shell/commands.js +78 -0
  147. package/dist/shell/index.d.ts +8 -0
  148. package/dist/shell/index.js +8 -0
  149. package/dist/shell/keys.d.ts +41 -0
  150. package/dist/shell/keys.js +134 -0
  151. package/dist/shell/shell-module.d.ts +31 -0
  152. package/dist/shell/shell-module.js +37 -0
  153. package/dist/shell/window-manager.d.ts +124 -0
  154. package/dist/shell/window-manager.js +711 -0
  155. package/dist/shell/workbench-contract.d.ts +68 -0
  156. package/dist/shell/workbench-contract.js +1 -0
  157. package/dist/shell/workbench-host-types.d.ts +96 -0
  158. package/dist/shell/workbench-host-types.js +1 -0
  159. package/dist/shell/workbench-host.d.ts +34 -0
  160. package/dist/shell/workbench-host.js +347 -0
  161. package/dist/shell/workbench-model.d.ts +192 -0
  162. package/dist/shell/workbench-model.js +53 -0
  163. package/dist/shell/workbench.d.ts +4 -0
  164. package/dist/shell/workbench.js +2 -0
  165. package/dist/source/fork.d.ts +27 -0
  166. package/dist/source/fork.js +46 -0
  167. package/dist/source/index.d.ts +11 -0
  168. package/dist/source/index.js +11 -0
  169. package/dist/source/seed-receipt.d.ts +53 -0
  170. package/dist/source/seed-receipt.js +18 -0
  171. package/dist/source/team-source.d.ts +32 -0
  172. package/dist/source/team-source.js +42 -0
  173. package/dist/source/template-repository.d.ts +2 -0
  174. package/dist/source/template-repository.js +8 -0
  175. package/dist/source/template-write.d.ts +69 -0
  176. package/dist/source/template-write.js +344 -0
  177. package/dist/storage-frame.d.ts +64 -0
  178. package/dist/storage-frame.js +28 -0
  179. package/dist/tenant.d.ts +81 -0
  180. package/dist/tenant.js +71 -0
  181. package/dist/testing/capabilities.d.ts +29 -0
  182. package/dist/testing/capabilities.js +130 -0
  183. package/dist/testing/catalog.d.ts +3 -0
  184. package/dist/testing/catalog.js +53 -0
  185. package/dist/testing/index.d.ts +10 -0
  186. package/dist/testing/index.js +10 -0
  187. package/dist/testing/installation.d.ts +11 -0
  188. package/dist/testing/installation.js +25 -0
  189. package/dist/testing/native-worker.d.ts +21 -0
  190. package/dist/testing/native-worker.js +106 -0
  191. package/dist/testing/worker.d.ts +18 -0
  192. package/dist/testing/worker.js +67 -0
  193. package/dist/testing/workers-runtime.d.ts +24 -0
  194. package/dist/testing/workers-runtime.js +33 -0
  195. package/dist/webhook-delivery.d.ts +14 -0
  196. package/dist/webhook-delivery.js +17 -0
  197. package/dist/workers-git.d.ts +285 -0
  198. package/dist/workers-git.js +1174 -0
  199. package/dist/workers.d.ts +352 -0
  200. package/dist/workers.js +866 -0
  201. package/docs/api/README.md +82 -0
  202. package/docs/api/agents.md +332 -0
  203. package/docs/api/app-storage.md +209 -0
  204. package/docs/api/apps.md +1375 -0
  205. package/docs/api/auth.md +63 -0
  206. package/docs/api/build-contract.md +507 -0
  207. package/docs/api/build.md +1476 -0
  208. package/docs/api/cloudflare.md +421 -0
  209. package/docs/api/computer.md +203 -0
  210. package/docs/api/files.md +117 -0
  211. package/docs/api/git.md +470 -0
  212. package/docs/api/host.md +1328 -0
  213. package/docs/api/index.md +430 -0
  214. package/docs/api/invocation.md +738 -0
  215. package/docs/api/keys.md +175 -0
  216. package/docs/api/mcp.md +301 -0
  217. package/docs/api/media.md +40 -0
  218. package/docs/api/modules.md +42 -0
  219. package/docs/api/pack.md +138 -0
  220. package/docs/api/page/mount.md +298 -0
  221. package/docs/api/root-data.md +111 -0
  222. package/docs/api/shell/app-directory.md +332 -0
  223. package/docs/api/shell/command-host.md +224 -0
  224. package/docs/api/shell/commands.md +197 -0
  225. package/docs/api/shell/keys.md +183 -0
  226. package/docs/api/shell/shell-module.md +143 -0
  227. package/docs/api/shell/window-manager.md +267 -0
  228. package/docs/api/shell/workbench.md +186 -0
  229. package/docs/api/shell.md +1788 -0
  230. package/docs/api/tenant.md +194 -0
  231. package/docs/api/workers-git.md +1527 -0
  232. package/docs/api/workers.md +1678 -0
  233. package/docs/media/architecture.mmd +32 -0
  234. package/docs/media/architecture.png +0 -0
  235. package/docs/media/bonsai-network.png +0 -0
  236. package/docs/media/bonsai-repository.png +0 -0
  237. package/docs/site/agent-quickstart.mdx +349 -0
  238. package/examples/git/worker.ts +39 -0
  239. package/examples/git/wrangler.jsonc +32 -0
  240. package/examples/standalone/worker.ts +14 -0
  241. package/examples/standalone/wrangler.jsonc +21 -0
  242. package/package.json +249 -3
@@ -0,0 +1,188 @@
1
+ /** One admitted path for external and installation-authored repository writes. */
2
+ import { appGitPushFactSchema, gitPushQuestionSchema, gitPushVerdictSchema, gitWriterSchema, } from "@fungi.computer/agar/git";
3
+ import { gitRepositoryNameSchema } from "@fungi.computer/puffball/git-representation";
4
+ import { acceptedRefs, parseReceivePack, refusalReport, } from "@fungi.computer/agar/git-wire";
5
+ import { defineIdFamily } from "@fungi.computer/cairn/id";
6
+ /** Reuse the live installation/verified Release rule without opening provider storage. */
7
+ export function createInstallationGitAdmission(ports) {
8
+ return async (writer) => {
9
+ const current = await ports.currentInstallation();
10
+ if (current === null)
11
+ return false;
12
+ const declaration = await ports.declaration(current);
13
+ return (declaration?.capabilities.includes("git.repository") === true &&
14
+ (writer !== null || current.visibility === "public"));
15
+ };
16
+ }
17
+ const ids = defineIdFamily({
18
+ family: "oyster-git-push",
19
+ version: 1,
20
+ kinds: { event: { prefix: "gitpush" } },
21
+ });
22
+ const ZERO = "0".repeat(40);
23
+ const MAX_BYTES = 64 * 1024 * 1024;
24
+ /** Fixed order: host grant, Workflow ceiling, App policy, provider exchange, durable journal. */
25
+ export function createAdmittedGit(ports) {
26
+ const { store, grants, policy, journal } = ports;
27
+ async function admit(writer, repository, scope) {
28
+ gitRepositoryNameSchema.parse(repository);
29
+ if (writer !== null)
30
+ gitWriterSchema.parse(writer);
31
+ if (writer?.kind === "installation" &&
32
+ writer.installationId !== ports.installationId)
33
+ throw new Error("Git installation authority mismatch");
34
+ if (!(await grants.admits(writer, repository, scope)))
35
+ throw new Error("Git host grant refused");
36
+ ports.beforeProvider?.();
37
+ }
38
+ const refusal = (updates, capabilities, verdict) => ({
39
+ status: 200,
40
+ headers: {
41
+ "content-type": "application/x-git-receive-pack-result",
42
+ "cache-control": "no-store",
43
+ },
44
+ body: refusalReport(verdict, updates, capabilities),
45
+ });
46
+ async function exchange(writer, repository, request) {
47
+ if (request.service !== "receive-pack") {
48
+ await admit(writer, repository, "read");
49
+ return store.exchange(repository, request);
50
+ }
51
+ if (request.phase === "advertise") {
52
+ await admit(writer, repository, "write");
53
+ return store.exchange(repository, request);
54
+ }
55
+ if (request.body === null || request.body.byteLength > MAX_BYTES)
56
+ throw new Error("Invalid or oversized Git push");
57
+ const parsed = parseReceivePack(request.body);
58
+ if (parsed.kind === "invalid")
59
+ return {
60
+ status: 400,
61
+ headers: {},
62
+ body: new TextEncoder().encode(`Invalid Git push: ${parsed.reason}`),
63
+ };
64
+ if (parsed.kind === "probe") {
65
+ // Git's pre-flight for a chunked push asks only whether a write would
66
+ // be admitted; the pack follows in the next request.
67
+ await admit(writer, repository, "write");
68
+ return {
69
+ status: 200,
70
+ headers: {
71
+ "content-type": "application/x-git-receive-pack-result",
72
+ "cache-control": "no-store",
73
+ },
74
+ body: new Uint8Array(),
75
+ };
76
+ }
77
+ const { updates, capabilities } = parsed;
78
+ await admit(writer, repository, "write");
79
+ if (writer.kind === "actor") {
80
+ const outside = writer.workflow === undefined
81
+ ? []
82
+ : updates.filter(({ ref }) => !writer.workflow?.branches.includes(ref));
83
+ if (outside.length > 0)
84
+ return refusal(updates, capabilities, {
85
+ allowed: false,
86
+ refused: outside.map(({ ref }) => ({
87
+ ref,
88
+ message: "outside the Workflow branch grant",
89
+ })),
90
+ });
91
+ const question = gitPushQuestionSchema.parse({
92
+ repository,
93
+ writer,
94
+ updates,
95
+ });
96
+ const verdict = gitPushVerdictSchema.parse(await policy.authorize(question));
97
+ if (!verdict.allowed)
98
+ return refusal(updates, capabilities, verdict);
99
+ }
100
+ const unexplained = [];
101
+ for (const { ref, before } of updates) {
102
+ const tip = await journal.tip(repository, ref);
103
+ if ((before === ZERO ? null : before) !== tip)
104
+ unexplained.push({ ref, journaled: tip });
105
+ }
106
+ // Reopen the grant after asynchronous App/journal work, immediately before I/O.
107
+ await admit(writer, repository, "write");
108
+ const response = await store.exchange(repository, request);
109
+ if (response.status < 200 || response.status >= 300)
110
+ return response;
111
+ const accepted = acceptedRefs(response.body, capabilities.includes("side-band") ||
112
+ capabilities.includes("side-band-64k"));
113
+ const moved = updates.filter(({ ref }) => accepted.has(ref));
114
+ if (moved.length > 0) {
115
+ const fact = appGitPushFactSchema.parse({
116
+ repository,
117
+ writer,
118
+ updates: moved,
119
+ eventId: ids.event.generate(),
120
+ });
121
+ const unknownMoves = unexplained.filter(({ ref }) => accepted.has(ref));
122
+ if (unknownMoves.length > 0)
123
+ fact.unexplained = unknownMoves;
124
+ await journal.record(fact);
125
+ }
126
+ return response;
127
+ }
128
+ return {
129
+ push: exchange,
130
+ async read(writer, repository, request) {
131
+ if (request.service !== "upload-pack")
132
+ throw new Error("Git read cannot push");
133
+ await admit(writer, repository, "read");
134
+ return store.exchange(repository, request);
135
+ },
136
+ binding(onBehalfOf) {
137
+ const writer = {
138
+ kind: "installation",
139
+ installationId: ports.installationId,
140
+ onBehalfOf,
141
+ };
142
+ return {
143
+ async create(name, options) {
144
+ await admit(writer, name, "write");
145
+ return store.create(name, options);
146
+ },
147
+ async fork(source, target, options) {
148
+ await admit(writer, source, "read");
149
+ await admit(writer, target, "write");
150
+ return store.fork(source, target, options);
151
+ },
152
+ async cloneUrl(name) {
153
+ await admit(writer, name, "read");
154
+ return store.cloneUrl(name);
155
+ },
156
+ async info(name) {
157
+ await admit(writer, name, "read");
158
+ return store.info(name);
159
+ },
160
+ async delete(name) {
161
+ await admit(writer, name, "write");
162
+ return store.delete(name);
163
+ },
164
+ async log(name, options) {
165
+ await admit(writer, name, "read");
166
+ return store.log(name, options);
167
+ },
168
+ async readCommit(name, hash) {
169
+ await admit(writer, name, "read");
170
+ return store.readCommit(name, hash);
171
+ },
172
+ async readTree(name, hash) {
173
+ await admit(writer, name, "read");
174
+ return store.readTree(name, hash);
175
+ },
176
+ async readBlob(name, hash) {
177
+ await admit(writer, name, "read");
178
+ return store.readBlob(name, hash);
179
+ },
180
+ async refs(name) {
181
+ await admit(writer, name, "read");
182
+ return store.refs(name);
183
+ },
184
+ exchange: (name, request) => exchange(writer, name, request),
185
+ };
186
+ },
187
+ };
188
+ }
@@ -0,0 +1,11 @@
1
+ /** Credential parsing never logs the presented header or its decoding error. */
2
+ /** A Workflow Git pass presented as `Authorization: Bearer` starts with this. */
3
+ export declare const workflowGitPassPrefix = "wgp.";
4
+ /** A build job's clone pass starts with this; only the host's egress presents it. */
5
+ export declare const buildGitPassPrefix = "obp.";
6
+ export declare function presentedGitKey(request: Request): Readonly<{
7
+ kind: "key";
8
+ value: string;
9
+ }> | Readonly<{
10
+ kind: "absent" | "invalid";
11
+ }>;
@@ -0,0 +1,27 @@
1
+ /** Credential parsing never logs the presented header or its decoding error. */
2
+ /** A Workflow Git pass presented as `Authorization: Bearer` starts with this. */
3
+ export const workflowGitPassPrefix = "wgp.";
4
+ /** A build job's clone pass starts with this; only the host's egress presents it. */
5
+ export const buildGitPassPrefix = "obp.";
6
+ export function presentedGitKey(request) {
7
+ const header = request.headers.get("Authorization");
8
+ if (header === null)
9
+ return { kind: "absent" };
10
+ if (header.startsWith("Bearer ")) {
11
+ const value = header.slice(7).trim();
12
+ return value ? { kind: "key", value } : { kind: "invalid" };
13
+ }
14
+ if (!header.startsWith("Basic "))
15
+ return { kind: "invalid" };
16
+ try {
17
+ const decoded = atob(header.slice(6).trim());
18
+ const colon = decoded.indexOf(":");
19
+ const value = colon === -1 ? "" : decoded.slice(colon + 1);
20
+ return value ? { kind: "key", value } : { kind: "invalid" };
21
+ }
22
+ catch (error) {
23
+ if (error instanceof DOMException && error.name === "InvalidCharacterError")
24
+ return { kind: "invalid" };
25
+ throw error;
26
+ }
27
+ }
@@ -0,0 +1,35 @@
1
+ /** Smart-HTTP routing; all writes enter the same admitted Git path. */
2
+ import { Hono } from "hono";
3
+ import type { GitWriter } from "@fungi.computer/agar/git";
4
+ import type { AdmittedGit } from "./git-admission.js";
5
+ /** The repository and access one smart-HTTP request names. */
6
+ export type GitDoorTarget = Readonly<{
7
+ installationId: string;
8
+ repository: string;
9
+ scope: "read" | "write";
10
+ }>;
11
+ /** Identity and selected installation come only from host-held ports. */
12
+ export type GitDoorHost = Readonly<{
13
+ /** Resolve a host-authenticated actor for this exact repository and scope;
14
+ * null means an anonymous request. */
15
+ authenticate(request: Request, target: GitDoorTarget): Promise<Extract<GitWriter, {
16
+ kind: "actor";
17
+ }> | null>;
18
+ /** Decide whether anonymous reads may reach the selected installation. */
19
+ isPublic(installationId: string): Promise<boolean>;
20
+ /** Resolve the installation's admitted path without exposing provider credentials. */
21
+ git(installationId: string): Promise<AdmittedGit | null>;
22
+ }>;
23
+ /** One smart-HTTP exchange: the method, `service` query and path suffix a
24
+ * Git client sends, whatever path names the repository. Null otherwise. */
25
+ export declare function smartHttpExchange(method: string, url: URL, suffix: string): Readonly<{
26
+ scope: "read" | "write";
27
+ advertise: boolean;
28
+ }> | null;
29
+ /** Oyster's own Git address: the repository, scope and phase one request
30
+ * names, or null when it is not an exact Oyster Git exchange. */
31
+ export declare function gitDoorRoute(method: string, url: URL): (GitDoorTarget & Readonly<{
32
+ advertise: boolean;
33
+ }>) | null;
34
+ /** Mount one smart-HTTP door with no provider credentials or alternate write branch. */
35
+ export declare function createGitDoor(host: GitDoorHost): Hono;
@@ -0,0 +1,108 @@
1
+ /** Smart-HTTP routing; all writes enter the same admitted Git path. */
2
+ import { Hono } from "hono";
3
+ import { z } from "zod";
4
+ const route = /^\/git\/([a-zA-Z0-9_-]{1,256})\/([a-zA-Z0-9._-]{1,128}?)(?:\.git)?\/(info\/refs|git-upload-pack|git-receive-pack)$/u;
5
+ /** One smart-HTTP exchange: the method, `service` query and path suffix a
6
+ * Git client sends, whatever path names the repository. Null otherwise. */
7
+ export function smartHttpExchange(method, url, suffix) {
8
+ const advertise = suffix === "info/refs";
9
+ const service = advertise ? url.searchParams.get("service") : suffix;
10
+ if ((service !== "git-upload-pack" && service !== "git-receive-pack") ||
11
+ method !== (advertise ? "GET" : "POST") ||
12
+ (advertise ? url.search !== `?service=${service}` : url.search !== ""))
13
+ return null;
14
+ return {
15
+ scope: service === "git-receive-pack" ? "write" : "read",
16
+ advertise,
17
+ };
18
+ }
19
+ /** Oyster's own Git address: the repository, scope and phase one request
20
+ * names, or null when it is not an exact Oyster Git exchange. */
21
+ export function gitDoorRoute(method, url) {
22
+ const match = route.exec(url.pathname);
23
+ if (match === null)
24
+ return null;
25
+ const [, installationId = "", repository = "", suffix = ""] = match;
26
+ const exchange = smartHttpExchange(method, url, suffix);
27
+ return exchange === null ? null : { installationId, repository, ...exchange };
28
+ }
29
+ const MAX_BYTES = 64 * 1024 * 1024;
30
+ const byteChunk = z.instanceof(Uint8Array);
31
+ async function bodyOf(request) {
32
+ if (request.body === null)
33
+ return new Uint8Array();
34
+ const reader = request.body.getReader();
35
+ const parts = [];
36
+ let size = 0;
37
+ try {
38
+ for (;;) {
39
+ const next = await reader.read();
40
+ if (next.done)
41
+ break;
42
+ const chunk = byteChunk.parse(next.value);
43
+ size += chunk.byteLength;
44
+ if (size > MAX_BYTES) {
45
+ await reader.cancel();
46
+ return new Response("Git request too large", { status: 413 });
47
+ }
48
+ parts.push(chunk);
49
+ }
50
+ }
51
+ finally {
52
+ reader.releaseLock();
53
+ }
54
+ const body = new Uint8Array(size);
55
+ let offset = 0;
56
+ for (const part of parts) {
57
+ body.set(part, offset);
58
+ offset += part.byteLength;
59
+ }
60
+ return body;
61
+ }
62
+ /** Mount one smart-HTTP door with no provider credentials or alternate write branch. */
63
+ export function createGitDoor(host) {
64
+ const door = new Hono();
65
+ const smartHttp = async (context) => {
66
+ const routed = gitDoorRoute(context.req.method, new URL(context.req.url));
67
+ if (routed === null)
68
+ return context.text("Not a git route", 404);
69
+ const { advertise, ...target } = routed;
70
+ const { installationId, repository } = target;
71
+ const writing = target.scope === "write";
72
+ const writer = await host.authenticate(context.req.raw, target);
73
+ if (writer === null && (writing || !(await host.isPublic(installationId))))
74
+ return new Response("A key is required", {
75
+ status: 401,
76
+ headers: { "WWW-Authenticate": 'Basic realm="Oyster git"' },
77
+ });
78
+ const git = await host.git(installationId);
79
+ if (git === null)
80
+ return context.text("Installation Git unavailable", 403);
81
+ if (context.req.header("content-encoding") !== undefined)
82
+ return context.text("Compressed Git requests are not supported", 400);
83
+ const body = advertise ? null : await bodyOf(context.req.raw);
84
+ if (body instanceof Response)
85
+ return body;
86
+ const headers = {};
87
+ for (const name of ["content-type", "accept", "git-protocol"]) {
88
+ const value = context.req.header(name);
89
+ if (value !== undefined)
90
+ headers[name] = value;
91
+ }
92
+ const request = {
93
+ service: writing ? "receive-pack" : "upload-pack",
94
+ phase: advertise ? "advertise" : "exchange",
95
+ headers,
96
+ body,
97
+ };
98
+ const response = writing && writer !== null
99
+ ? await git.push(writer, repository, request)
100
+ : await git.read(writer, repository, request);
101
+ return new Response(new Uint8Array(response.body), {
102
+ status: response.status,
103
+ headers: response.headers,
104
+ });
105
+ };
106
+ door.all("/git/*", smartHttp);
107
+ return door;
108
+ }
@@ -0,0 +1,31 @@
1
+ import { type AppGitPushFact } from "@fungi.computer/agar/git";
2
+ import { type BackgroundWake } from "@fungi.computer/puffball/background-replay";
3
+ import { type PostDeliveryResult } from "@fungi.computer/carrier/post";
4
+ import { type WatchdogSqliteOwner } from "@fungi.computer/watchdog";
5
+ /** The standalone composition supplies selected-Instance admission and its alarm. */
6
+ export type GitPushJournalPorts = Readonly<{
7
+ owner: WatchdogSqliteOwner;
8
+ /** Deliver a retained fact through the selected installation's lifecycle admission. */
9
+ deliver(installationId: string, fact: AppGitPushFact): Promise<void>;
10
+ /** Re-admit a retained member through the host gate before an ordinary continuation call. */
11
+ deliverSync(wake: BackgroundWake): Promise<PostDeliveryResult>;
12
+ /** Arm no later than an already scheduled alarm. */
13
+ scheduleWake(at: number): Promise<void>;
14
+ /** Host clock for delivery scheduling; defaults to Date.now. */
15
+ now?: () => number;
16
+ }>;
17
+ /** The immutable Carrier key also fits the SDK's bounded call idempotency key. */
18
+ export declare function backgroundWakeKey(wake: Pick<BackgroundWake, "installationId" | "lane" | "epoch">): string;
19
+ /** Carrier owns delivery state; this journal owns accepted fact identity. */
20
+ export declare function createGitPushJournal(ports: GitPushJournalPorts): Promise<{
21
+ /** Coalesce a pending member lane; terminal receipts receive a new immutable epoch. */
22
+ armSync(intent: Omit<BackgroundWake, "epoch">): Promise<number>;
23
+ /** Last journaled ref head; null includes a journaled deletion. */
24
+ tip(installation: string, repository: string, ref: string): string | null;
25
+ /** Record a host-accepted push; provider acceptance precedes this local transaction. */
26
+ accept(installation: string, raw: AppGitPushFact): Promise<"duplicate" | "recorded">;
27
+ /** One bounded alarm turn; Carrier retains unfinished work and retry backoff. */
28
+ alarm(): Promise<void>;
29
+ /** The next retained delivery deadline, or null when no wake is needed. */
30
+ nextWake: () => number | null;
31
+ }>;
@@ -0,0 +1,188 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as Schema from "effect/Schema";
3
+ import { z } from "zod";
4
+ import { appGitPushFactSchema, } from "@fungi.computer/agar/git";
5
+ import { backgroundWakeSchema, } from "@fungi.computer/puffball/background-replay";
6
+ import { createPost, definePostRoute, } from "@fungi.computer/carrier/post";
7
+ import { Watchdog, } from "@fungi.computer/watchdog";
8
+ const storedFact = z.strictObject({ fact: z.string() });
9
+ const storedEpoch = z.strictObject({
10
+ epoch: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
11
+ });
12
+ const syncKey = (installationId, lane, epoch) => `appsync:${createHash("sha256")
13
+ .update(JSON.stringify([installationId, lane, epoch]))
14
+ .digest("hex")}`;
15
+ /** The immutable Carrier key also fits the SDK's bounded call idempotency key. */
16
+ export function backgroundWakeKey(wake) {
17
+ return syncKey(wake.installationId, wake.lane, wake.epoch);
18
+ }
19
+ const installationIdSchema = z.string().min(1).max(256);
20
+ const postKey = (installationId, eventId) => `gitpush:${createHash("sha256")
21
+ .update(JSON.stringify([installationId, eventId]))
22
+ .digest("hex")}`;
23
+ /** Carrier owns delivery state; this journal owns accepted fact identity. */
24
+ export async function createGitPushJournal(ports) {
25
+ const { owner } = ports;
26
+ const now = ports.now ?? Date.now;
27
+ owner.sql.exec("CREATE TABLE IF NOT EXISTS oyster_git_push_facts (installation_id TEXT NOT NULL, event_id TEXT NOT NULL, fact TEXT NOT NULL, PRIMARY KEY (installation_id,event_id)) STRICT");
28
+ owner.sql.exec("CREATE TABLE IF NOT EXISTS oyster_git_tips (installation_id TEXT NOT NULL, repository TEXT NOT NULL, ref TEXT NOT NULL, hash TEXT NOT NULL, PRIMARY KEY(installation_id,repository,ref)) STRICT");
29
+ owner.sql.exec("CREATE TABLE IF NOT EXISTS oyster_app_sync_epochs (installation_id TEXT NOT NULL, lane TEXT NOT NULL, epoch INTEGER NOT NULL, PRIMARY KEY(installation_id,lane)) STRICT");
30
+ const deliveringSync = new Set();
31
+ const routes = {
32
+ appSync: definePostRoute({
33
+ addressKind: "appSync",
34
+ address: Schema.String,
35
+ event: Schema.String,
36
+ name: (address) => address,
37
+ namespace: {
38
+ getByName: () => ({
39
+ deliver: (envelope) => {
40
+ const encoded = Schema.decodeUnknownSync(Schema.String)(envelope.event);
41
+ const wake = backgroundWakeSchema.parse(JSON.parse(encoded));
42
+ const key = syncKey(wake.installationId, wake.lane, wake.epoch);
43
+ deliveringSync.add(key);
44
+ return ports.deliverSync(wake);
45
+ },
46
+ }),
47
+ },
48
+ }),
49
+ installation: definePostRoute({
50
+ addressKind: "installation",
51
+ address: Schema.String,
52
+ // The canonical Agar decoder owns the fact grammar, including stored JSON.
53
+ event: Schema.String,
54
+ name: (address) => address,
55
+ namespace: {
56
+ getByName: (installationId) => ({
57
+ deliver: async (envelope) => {
58
+ const raw = Schema.decodeUnknownSync(Schema.String)(envelope.event);
59
+ const fact = appGitPushFactSchema.parse(JSON.parse(raw));
60
+ await ports.deliver(installationId, fact);
61
+ return { status: "accepted", receipt: { eventId: fact.eventId } };
62
+ },
63
+ }),
64
+ },
65
+ }),
66
+ };
67
+ const scheduleNext = async () => {
68
+ const at = post.nextWake();
69
+ if (at !== null)
70
+ await ports.scheduleWake(at);
71
+ };
72
+ const watchdog = await Watchdog.make({
73
+ owner,
74
+ isAlive: () => Promise.resolve(false),
75
+ execute: (job) => post.execute(job),
76
+ wake: { recompute: scheduleNext },
77
+ });
78
+ const post = createPost({
79
+ owner,
80
+ routes,
81
+ transactional: watchdog.transactional,
82
+ readJob: watchdog.read,
83
+ now,
84
+ scheduleWake: ports.scheduleWake,
85
+ });
86
+ let settling;
87
+ const drain = async () => {
88
+ let cursor;
89
+ do {
90
+ cursor = await post.enqueueDue(64, cursor);
91
+ } while (cursor !== undefined);
92
+ try {
93
+ for (let count = 0; count < 64; count += 1) {
94
+ if ((await watchdog.tick()).status !== "settled" ||
95
+ deliveringSync.size > 0)
96
+ break;
97
+ }
98
+ }
99
+ finally {
100
+ // Keep delivery marked until Carrier's receipt transaction has finished.
101
+ deliveringSync.clear();
102
+ }
103
+ await scheduleNext();
104
+ };
105
+ await scheduleNext();
106
+ return {
107
+ /** Coalesce a pending member lane; terminal receipts receive a new immutable epoch. */
108
+ async armSync(intent) {
109
+ const identity = backgroundWakeSchema.parse({ ...intent, epoch: 1 });
110
+ const wake = owner.transactionSync(() => {
111
+ const row = owner.sql
112
+ .exec("SELECT epoch FROM oyster_app_sync_epochs WHERE installation_id=? AND lane=?", identity.installationId, identity.lane)
113
+ .toArray()[0];
114
+ const previous = row === undefined ? 0 : storedEpoch.parse(row).epoch;
115
+ if (previous > 0 &&
116
+ !deliveringSync.has(syncKey(identity.installationId, identity.lane, previous)) &&
117
+ post.read(syncKey(identity.installationId, identity.lane, previous))
118
+ ?.state === "pending")
119
+ return previous;
120
+ const epoch = previous + 1;
121
+ const next = backgroundWakeSchema.parse({ ...identity, epoch });
122
+ owner.sql.exec("INSERT INTO oyster_app_sync_epochs VALUES(?,?,?) ON CONFLICT(installation_id,lane) DO UPDATE SET epoch=excluded.epoch", next.installationId, next.lane, next.epoch);
123
+ post.send({
124
+ key: syncKey(next.installationId, next.lane, next.epoch),
125
+ kind: "appSync",
126
+ address: createHash("sha256")
127
+ .update(JSON.stringify([next.installationId, next.lane]))
128
+ .digest("hex"),
129
+ event: JSON.stringify(next),
130
+ });
131
+ return epoch;
132
+ });
133
+ await scheduleNext();
134
+ return wake;
135
+ },
136
+ /** Last journaled ref head; null includes a journaled deletion. */
137
+ tip(installation, repository, ref) {
138
+ const row = owner.sql
139
+ .exec("SELECT hash FROM oyster_git_tips WHERE installation_id=? AND repository=? AND ref=?", installationIdSchema.parse(installation), repository, ref)
140
+ .toArray()[0];
141
+ return row === undefined
142
+ ? null
143
+ : z
144
+ .strictObject({ hash: z.string().regex(/^[0-9a-f]{40}$/u) })
145
+ .parse(row).hash;
146
+ },
147
+ /** Record a host-accepted push; provider acceptance precedes this local transaction. */
148
+ async accept(installation, raw) {
149
+ const installationId = installationIdSchema.parse(installation);
150
+ const fact = appGitPushFactSchema.parse(raw);
151
+ const encoded = JSON.stringify(fact);
152
+ const result = owner.transactionSync(() => {
153
+ const previous = owner.sql
154
+ .exec("SELECT fact FROM oyster_git_push_facts WHERE installation_id=? AND event_id=?", installationId, fact.eventId)
155
+ .toArray()[0];
156
+ if (previous !== undefined) {
157
+ if (storedFact.parse(previous).fact !== encoded)
158
+ throw new Error("Git push event conflicts with its accepted fact");
159
+ return "duplicate";
160
+ }
161
+ owner.sql.exec("INSERT INTO oyster_git_push_facts VALUES (?,?,?)", installationId, fact.eventId, encoded);
162
+ for (const update of fact.updates) {
163
+ if (update.after === "0".repeat(40))
164
+ owner.sql.exec("DELETE FROM oyster_git_tips WHERE installation_id=? AND repository=? AND ref=?", installationId, fact.repository, update.ref);
165
+ else
166
+ owner.sql.exec("INSERT INTO oyster_git_tips VALUES(?,?,?,?) ON CONFLICT(installation_id,repository,ref) DO UPDATE SET hash=excluded.hash", installationId, fact.repository, update.ref, update.after);
167
+ }
168
+ post.send({
169
+ key: postKey(installationId, fact.eventId),
170
+ kind: "installation",
171
+ address: installationId,
172
+ event: encoded,
173
+ });
174
+ return "recorded";
175
+ });
176
+ await scheduleNext();
177
+ return result;
178
+ },
179
+ /** One bounded alarm turn; Carrier retains unfinished work and retry backoff. */
180
+ alarm() {
181
+ return (settling ??= drain().finally(() => {
182
+ settling = undefined;
183
+ }));
184
+ },
185
+ /** The next retained delivery deadline, or null when no wake is needed. */
186
+ nextWake: () => post.nextWake(),
187
+ };
188
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { GitBinding } from "@fungi.computer/agar/git";
2
+ import * as Schema from "effect/Schema";
3
+ /** The EVENTS binding: an App emits typed events the host routes. */
4
+ export type EventSink = Readonly<{
5
+ /**
6
+ * Emit one event. `payload` is the Standard Webhooks body
7
+ * (type, timestamp, data); `eventId` is stable so redelivery is a no-op.
8
+ */
9
+ emit(type: string, payload: StandardWebhookPayload, eventId: string): Promise<void>;
10
+ }>;
11
+ /** The Standard Webhooks payload shape. */
12
+ export type StandardWebhookPayload = Readonly<{
13
+ type: string;
14
+ timestamp: string;
15
+ data: Schema.Json;
16
+ }>;
17
+ /** The protocol-v2 ls-refs request body. */
18
+ export declare function lsRefsRequest(): Uint8Array;
19
+ /** Parse an ls-refs response into refs. */
20
+ export declare function parseLsRefs(response: Uint8Array): Awaited<ReturnType<GitBinding["refs"]>>;
21
+ /** The Standard Webhooks `webhook-signature` value for one delivery. */
22
+ export declare function signStandardWebhook(secret: string, id: string, timestamp: number, body: string): Promise<string>;
23
+ /** Headers for one signed delivery: Standard Webhooks plus `X-Hub-Signature-256`. */
24
+ export declare function webhookHeaders(secret: string, id: string, type: string, timestamp: number, body: string): Promise<Record<string, string>>;
25
+ export { createGitDoor, gitDoorRoute, smartHttpExchange } from "./git-door.js";
26
+ export type { GitDoorHost, GitDoorTarget } from "./git-door.js";
27
+ export { workflowGitPassPrefix } from "./git-auth.js";
28
+ export { createAdmittedGit, createInstallationGitAdmission, } from "./git-admission.js";
29
+ export type { AdmittedGit, GitStore, GitGrants, GitPolicy, GitJournal, } from "./git-admission.js";
30
+ export { createGitPushJournal } from "./git-journal.js";
31
+ export type { GitPushJournalPorts } from "./git-journal.js";