@intx/hub-sessions 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +82 -1
  3. package/dist/agent-repo.d.ts +93 -0
  4. package/dist/agent-repo.js +109 -0
  5. package/dist/agent-state-kind.d.ts +12 -0
  6. package/dist/agent-state-kind.js +189 -0
  7. package/dist/asset-service.d.ts +104 -0
  8. package/dist/asset-service.js +267 -0
  9. package/dist/committed-source-tree.d.ts +10 -0
  10. package/dist/committed-source-tree.js +35 -0
  11. package/dist/credential-push.d.ts +33 -0
  12. package/dist/credential-push.js +109 -0
  13. package/dist/event-collector-registry.d.ts +20 -0
  14. package/dist/event-collector-registry.js +115 -0
  15. package/dist/event-collector.d.ts +39 -0
  16. package/dist/event-collector.js +365 -0
  17. package/dist/hub-session-lookups.d.ts +135 -0
  18. package/dist/hub-session-lookups.js +663 -0
  19. package/dist/hub-session-orchestrator.d.ts +25 -0
  20. package/dist/hub-session-orchestrator.js +87 -0
  21. package/dist/index.d.ts +27 -0
  22. package/dist/index.js +24 -0
  23. package/dist/package-registry-kind.d.ts +70 -0
  24. package/dist/package-registry-kind.js +260 -0
  25. package/dist/repo-store/index.d.ts +4 -0
  26. package/dist/repo-store/index.js +3 -0
  27. package/dist/repo-store/store.d.ts +41 -0
  28. package/dist/repo-store/store.js +1829 -0
  29. package/dist/repo-store/subscribe-kind.d.ts +56 -0
  30. package/dist/repo-store/subscribe-kind.js +144 -0
  31. package/dist/repo-store/types.d.ts +571 -0
  32. package/dist/repo-store/types.js +42 -0
  33. package/dist/session-service.d.ts +416 -0
  34. package/dist/session-service.js +1191 -0
  35. package/dist/sidecar-allocation/contracts.d.ts +78 -0
  36. package/dist/sidecar-allocation/contracts.js +21 -0
  37. package/dist/sidecar-allocation/index.d.ts +4 -0
  38. package/dist/sidecar-allocation/index.js +3 -0
  39. package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
  40. package/dist/sidecar-allocation/placement-policy.js +21 -0
  41. package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
  42. package/dist/sidecar-allocation/plugin-registry.js +37 -0
  43. package/dist/sidecar-allocation/reconciler.d.ts +42 -0
  44. package/dist/sidecar-allocation/reconciler.js +431 -0
  45. package/dist/skill-kind.d.ts +41 -0
  46. package/dist/skill-kind.js +292 -0
  47. package/dist/substrate.d.ts +8 -0
  48. package/dist/substrate.js +21 -0
  49. package/dist/workflow-allocation-service.d.ts +58 -0
  50. package/dist/workflow-allocation-service.js +239 -0
  51. package/dist/workflow-closure-resolution.d.ts +106 -0
  52. package/dist/workflow-closure-resolution.js +123 -0
  53. package/dist/workflow-definition-ensure.d.ts +24 -0
  54. package/dist/workflow-definition-ensure.js +75 -0
  55. package/dist/workflow-dispatch-service.d.ts +40 -0
  56. package/dist/workflow-dispatch-service.js +146 -0
  57. package/dist/workflow-dispatch-settlement.d.ts +29 -0
  58. package/dist/workflow-dispatch-settlement.js +140 -0
  59. package/dist/workflow-kind.d.ts +37 -0
  60. package/dist/workflow-kind.js +310 -0
  61. package/dist/workflow-probe-gate.d.ts +214 -0
  62. package/dist/workflow-probe-gate.js +207 -0
  63. package/dist/workflow-run-event-log.d.ts +21 -0
  64. package/dist/workflow-run-event-log.js +51 -0
  65. package/dist/workflow-run-kind.d.ts +440 -0
  66. package/dist/workflow-run-kind.js +2916 -0
  67. package/dist/workflow-run-reader.d.ts +47 -0
  68. package/dist/workflow-run-reader.js +153 -0
  69. package/dist/workflow-run-restore.d.ts +15 -0
  70. package/dist/workflow-run-restore.js +26 -0
  71. package/dist/workflow-source-closure.d.ts +35 -0
  72. package/dist/workflow-source-closure.js +342 -0
  73. package/dist/ws/index.d.ts +3 -0
  74. package/dist/ws/index.js +3 -0
  75. package/dist/ws/sidecar-events.d.ts +222 -0
  76. package/dist/ws/sidecar-events.js +72 -0
  77. package/dist/ws/sidecar-handler.d.ts +305 -0
  78. package/dist/ws/sidecar-handler.js +2537 -0
  79. package/dist/ws/sidecar-token-authenticator.d.ts +17 -0
  80. package/dist/ws/sidecar-token-authenticator.js +81 -0
  81. package/package.json +35 -12
  82. package/src/agent-repo.test.ts +0 -310
  83. package/src/agent-repo.ts +0 -165
  84. package/src/agent-state-kind.test.ts +0 -247
  85. package/src/agent-state-kind.ts +0 -204
  86. package/src/asset-service.test.ts +0 -540
  87. package/src/asset-service.ts +0 -378
  88. package/src/available-skills-stanza.test.ts +0 -87
  89. package/src/available-skills-stanza.ts +0 -47
  90. package/src/credential-push.ts +0 -65
  91. package/src/event-collector-registry.test.ts +0 -73
  92. package/src/event-collector-registry.ts +0 -171
  93. package/src/event-collector.test.ts +0 -1387
  94. package/src/event-collector.ts +0 -424
  95. package/src/hub-session-lookups.ts +0 -206
  96. package/src/hub-session-orchestrator.test.ts +0 -510
  97. package/src/hub-session-orchestrator.ts +0 -213
  98. package/src/index.ts +0 -78
  99. package/src/repo-store/index.ts +0 -15
  100. package/src/repo-store/store.test.ts +0 -1169
  101. package/src/repo-store/store.ts +0 -428
  102. package/src/repo-store/types.ts +0 -253
  103. package/src/session-service.test.ts +0 -895
  104. package/src/session-service.ts +0 -464
  105. package/src/skill-kind.test.ts +0 -599
  106. package/src/skill-kind.ts +0 -350
  107. package/src/ws/index.ts +0 -18
  108. package/src/ws/sidecar-events.test.ts +0 -96
  109. package/src/ws/sidecar-events.ts +0 -231
  110. package/src/ws/sidecar-handler.test.ts +0 -2217
  111. package/src/ws/sidecar-handler.ts +0 -1574
  112. package/tsconfig.json +0 -4
  113. package/tsconfig.tsbuildinfo +0 -1
@@ -1,428 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import git from "isomorphic-git";
4
- import { createSSHSignature } from "@intx/crypto-node";
5
- import {
6
- initRepo as storageInitRepo,
7
- createDeployPack,
8
- receivePackObjects,
9
- type CommitSigner,
10
- } from "@intx/storage-isogit";
11
- import { hasCode } from "@intx/types";
12
- import { getLogger } from "@intx/log";
13
- import type {
14
- AuthorizeFn,
15
- InitRepoOpts,
16
- KindHandler,
17
- Principal,
18
- RefEntry,
19
- RepoAction,
20
- RepoId,
21
- RepoKind,
22
- RepoStore,
23
- TreeContent,
24
- } from "./types";
25
- import { SAFE_REPO_ID } from "./types";
26
-
27
- const AUTHOR = {
28
- name: "interchange-hub",
29
- email: "hub@interchange.local",
30
- };
31
-
32
- const logger = getLogger(["hub", "repo-store"]);
33
-
34
- type SigningKey = { privateKey: Uint8Array; publicKey: Uint8Array };
35
-
36
- /**
37
- * In-process push-serialization lock. Keyed by `${kind}/${id}`. Each
38
- * entry holds the tail of the chain of in-flight critical sections for
39
- * that repo; the next acquirer awaits the current tail and replaces it
40
- * with its own pending completion. On release the tail-check
41
- * (`if (locks.get(key) === myTail) locks.delete(key)`) prevents the map
42
- * from leaking entries once the chain drains.
43
- *
44
- * Single-process assumption: this protects against concurrent operations
45
- * inside a single hub instance. Cross-process serialization (e.g. a
46
- * second hub replica or an external git client touching the same
47
- * on-disk repo) would need a filesystem-backed lock; the migration path
48
- * is to swap the body of `withRepoLock` for an FS-lock acquire/release
49
- * around the same critical section.
50
- */
51
- const locks = new Map<string, Promise<void>>();
52
-
53
- async function withRepoLock<T>(
54
- repoId: RepoId,
55
- fn: () => Promise<T>,
56
- ): Promise<T> {
57
- const key = `${repoId.kind}/${repoId.id}`;
58
- const previous = locks.get(key) ?? Promise.resolve();
59
- let releaseFn: () => void = () => undefined;
60
- const tail = new Promise<void>((resolve) => {
61
- releaseFn = resolve;
62
- });
63
- locks.set(key, tail);
64
- try {
65
- await previous;
66
- return await fn();
67
- } finally {
68
- if (locks.get(key) === tail) {
69
- locks.delete(key);
70
- }
71
- releaseFn();
72
- }
73
- }
74
-
75
- export type CreateRepoStoreConfig = {
76
- dataDir: string;
77
- signingKey: SigningKey;
78
- /**
79
- * Handler map keyed by repo kind. The substrate throws at request
80
- * time when a kind has no registered handler, so callers may omit
81
- * kinds they do not service (e.g. a per-asset-kind store that only
82
- * registers `skill`).
83
- */
84
- handlers: Partial<Record<RepoKind, KindHandler>>;
85
- authorize: AuthorizeFn;
86
- /**
87
- * Optional per-repo signing callback. When supplied and the callback
88
- * returns a `CommitSigner` for the given `repoId`, the substrate
89
- * passes that signer to `initRepo` so the genesis commit is authored
90
- * as `interchange-hub` and signed. When the callback returns
91
- * `undefined`, or the field is omitted entirely, the substrate falls
92
- * back to the unsigned harness-authored genesis.
93
- */
94
- signingCallback?: (repoId: RepoId) => CommitSigner | undefined;
95
- };
96
-
97
- export function createRepoStore(config: CreateRepoStoreConfig): RepoStore {
98
- const { dataDir, signingKey, handlers, authorize, signingCallback } = config;
99
-
100
- function handlerFor(repoId: RepoId): KindHandler {
101
- const handler = handlers[repoId.kind];
102
- if (handler === undefined) {
103
- throw new Error(`no handler registered for kind: ${repoId.kind}`);
104
- }
105
- return handler;
106
- }
107
-
108
- function signerFor(repoId: RepoId): CommitSigner | undefined {
109
- return signingCallback === undefined ? undefined : signingCallback(repoId);
110
- }
111
-
112
- function repoDir(repoId: RepoId): string {
113
- if (!SAFE_REPO_ID.test(repoId.id)) {
114
- throw new Error(`repo_id_invalid: ${repoId.id}`);
115
- }
116
- const handler = handlerFor(repoId);
117
- return path.join(dataDir, handler.directoryPrefix, repoId.id);
118
- }
119
-
120
- function gateAccess(
121
- principal: Principal,
122
- repoId: RepoId,
123
- ref: string,
124
- action: RepoAction,
125
- ): void {
126
- const verdict = authorize(principal, repoId, ref, action);
127
- if (!verdict.allowed) {
128
- throw new Error(`authorize_denied: ${verdict.reason}`);
129
- }
130
- }
131
-
132
- function validateClearPrefix(clearPrefix: string): void {
133
- const malformed =
134
- clearPrefix.length === 0 ||
135
- !clearPrefix.endsWith("/") ||
136
- clearPrefix.startsWith("/") ||
137
- clearPrefix.split("/").includes("..");
138
- if (malformed) {
139
- throw new Error(`clear_prefix_invalid: ${clearPrefix}`);
140
- }
141
- }
142
-
143
- function storageOptsFor(
144
- repoId: RepoId,
145
- opts: InitRepoOpts | undefined,
146
- ): { signer?: CommitSigner; gitignore?: string } {
147
- const out: { signer?: CommitSigner; gitignore?: string } = {};
148
- const signer = signerFor(repoId);
149
- if (signer !== undefined) out.signer = signer;
150
- if (opts?.gitignore !== undefined) out.gitignore = opts.gitignore;
151
- return out;
152
- }
153
-
154
- async function initRepo(repoId: RepoId, opts?: InitRepoOpts): Promise<void> {
155
- await storageInitRepo(repoDir(repoId), storageOptsFor(repoId, opts));
156
- }
157
-
158
- function getRepoDir(repoId: RepoId): string {
159
- return repoDir(repoId);
160
- }
161
-
162
- async function listRefs(
163
- principal: Principal,
164
- repoId: RepoId,
165
- ): Promise<RefEntry[]> {
166
- gateAccess(principal, repoId, "*", "resolveRef");
167
- const dir = repoDir(repoId);
168
- const repoExists = await fs.promises
169
- .stat(path.join(dir, ".git"))
170
- .then(() => true)
171
- .catch(() => false);
172
- if (!repoExists) return [];
173
-
174
- const [branches, tags] = await Promise.all([
175
- git.listBranches({ fs, dir }),
176
- git.listTags({ fs, dir }),
177
- ]);
178
-
179
- const names: string[] = [];
180
- for (const b of branches) names.push(`refs/heads/${b}`);
181
- for (const t of tags) names.push(`refs/tags/${t}`);
182
- names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
183
-
184
- const entries: RefEntry[] = [];
185
- for (const name of names) {
186
- try {
187
- const sha = await git.resolveRef({ fs, dir, ref: name });
188
- entries.push({ name, sha });
189
- } catch (err: unknown) {
190
- if (hasCode(err) && err.code === "NotFoundError") continue;
191
- throw err;
192
- }
193
- }
194
- return entries;
195
- }
196
-
197
- async function resolveHead(
198
- principal: Principal,
199
- repoId: RepoId,
200
- ): Promise<{ symbolicTarget: string; sha: string } | null> {
201
- gateAccess(principal, repoId, "*", "resolveRef");
202
- const dir = repoDir(repoId);
203
- const repoExists = await fs.promises
204
- .stat(path.join(dir, ".git"))
205
- .then(() => true)
206
- .catch(() => false);
207
- if (!repoExists) return null;
208
-
209
- const symbolicTarget = await git.currentBranch({ fs, dir, fullname: true });
210
- if (symbolicTarget === undefined) return null;
211
-
212
- const sha = await resolveRefSha(dir, symbolicTarget);
213
- if (sha === null) return null;
214
-
215
- return { symbolicTarget, sha };
216
- }
217
-
218
- async function resolveRefSha(
219
- dir: string,
220
- ref: string,
221
- ): Promise<string | null> {
222
- try {
223
- return await git.resolveRef({ fs, dir, ref });
224
- } catch (err: unknown) {
225
- if (hasCode(err) && err.code === "NotFoundError") {
226
- return null;
227
- }
228
- throw err;
229
- }
230
- }
231
-
232
- async function clearIndexPrefix(dir: string, prefix: string): Promise<void> {
233
- const matrix = await git.statusMatrix({ fs, dir });
234
- for (const row of matrix) {
235
- const filepath = row[0];
236
- if (filepath.startsWith(prefix)) {
237
- await git.remove({ fs, dir, filepath });
238
- }
239
- }
240
- }
241
-
242
- async function writeFileEntry(
243
- dir: string,
244
- relPath: string,
245
- contents: string | Uint8Array,
246
- ): Promise<void> {
247
- const fullPath = path.join(dir, relPath);
248
- await fs.promises.mkdir(path.dirname(fullPath), { recursive: true });
249
- await fs.promises.writeFile(fullPath, contents);
250
- await git.add({ fs, dir, filepath: relPath });
251
- }
252
-
253
- async function writeTree(
254
- principal: Principal,
255
- repoId: RepoId,
256
- ref: string,
257
- content: TreeContent,
258
- ): Promise<{ commitSha: string }> {
259
- gateAccess(principal, repoId, ref, "writeTree");
260
-
261
- // The lock spans the entire substrate body of writeTree: index
262
- // mutation, validatePush, commit, and the onRefUpdated hook. Holding
263
- // the lock through onRefUpdated keeps post-update consumers
264
- // serialized against the same ref's next writer.
265
- return withRepoLock(repoId, async () => {
266
- const dir = repoDir(repoId);
267
- await storageInitRepo(dir, storageOptsFor(repoId, undefined));
268
-
269
- const handler = handlerFor(repoId);
270
-
271
- if (content.clearPrefix !== undefined) {
272
- validateClearPrefix(content.clearPrefix);
273
- await clearIndexPrefix(dir, content.clearPrefix);
274
- await fs.promises.rm(path.join(dir, content.clearPrefix), {
275
- recursive: true,
276
- force: true,
277
- });
278
- }
279
-
280
- for (const [relPath, contents] of Object.entries(content.files)) {
281
- await writeFileEntry(dir, relPath, contents);
282
- }
283
-
284
- const topLevelTreePaths = Array.from(
285
- new Set(
286
- Object.keys(content.files).map((p) => {
287
- const slash = p.indexOf("/");
288
- return slash === -1 ? p : p.substring(0, slash);
289
- }),
290
- ),
291
- );
292
- const readBlob = async (relPath: string): Promise<Uint8Array> => {
293
- const entry = content.files[relPath];
294
- if (entry === undefined) {
295
- throw new Error(
296
- `readBlob: path ${relPath} not present in tree content`,
297
- );
298
- }
299
- if (typeof entry === "string") {
300
- return new TextEncoder().encode(entry);
301
- }
302
- return entry;
303
- };
304
- const validation = await handler.validatePush({
305
- repoId,
306
- ref,
307
- topLevelTreePaths,
308
- readBlob,
309
- });
310
- if (!validation.ok) {
311
- throw new Error(`path_violation: ${validation.reason}`);
312
- }
313
-
314
- // Resolve the previous ref value for the post-update hook (precise:
315
- // null only when the ref truly doesn't exist) and the parent SHA for
316
- // the new commit (best-effort: any error falls back to HEAD, so a
317
- // first write on a never-touched ref produces a commit parented on
318
- // the repo's initial commit instead of failing).
319
- const oldSha = await resolveRefSha(dir, ref);
320
- let parentSha: string;
321
- try {
322
- parentSha = await git.resolveRef({ fs, dir, ref });
323
- } catch {
324
- parentSha = await git.resolveRef({ fs, dir, ref: "HEAD" });
325
- }
326
-
327
- const commitSha = await git.commit({
328
- fs,
329
- dir,
330
- message: content.message,
331
- author: AUTHOR,
332
- parent: [parentSha],
333
- ref,
334
- signingKey: "sshsig",
335
- onSign: async ({ payload }) => ({
336
- signature: createSSHSignature(
337
- payload,
338
- signingKey.privateKey,
339
- signingKey.publicKey,
340
- ),
341
- }),
342
- });
343
-
344
- await handler.onRefUpdated({ repoId, ref, oldSha, newSha: commitSha });
345
-
346
- return { commitSha };
347
- });
348
- }
349
-
350
- async function receivePack(
351
- principal: Principal,
352
- repoId: RepoId,
353
- ref: string,
354
- pack: Uint8Array,
355
- commitSha: string,
356
- expectedOldSha: string | null,
357
- ): Promise<void> {
358
- gateAccess(principal, repoId, ref, "receivePack");
359
-
360
- // The lock spans the entire substrate body of receivePack: the
361
- // packfile index, the CAS check against `expectedOldSha`, the
362
- // validateTree hook, the ref write, and the onRefUpdated hook.
363
- // `oldSha` for onRefUpdated is taken from the receivePackObjects
364
- // return value so the post-update hook sees the same pre-image the
365
- // CAS read observed, without a second resolveRef.
366
- return withRepoLock(repoId, async () => {
367
- const dir = repoDir(repoId);
368
- await storageInitRepo(dir, storageOptsFor(repoId, undefined));
369
-
370
- const handler = handlerFor(repoId);
371
- const transferId = crypto.randomUUID().replace(/-/g, "");
372
-
373
- const oldSha = await receivePackObjects(
374
- dir,
375
- pack,
376
- ref,
377
- commitSha,
378
- transferId,
379
- expectedOldSha,
380
- async (paths, readBlob) => {
381
- const result = await handler.validatePush({
382
- repoId,
383
- ref,
384
- topLevelTreePaths: paths,
385
- readBlob,
386
- });
387
- if (!result.ok) {
388
- logger.debug`validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${result.reason}`;
389
- return { ok: false, reason: result.reason };
390
- }
391
- return true;
392
- },
393
- );
394
-
395
- await handler.onRefUpdated({ repoId, ref, oldSha, newSha: commitSha });
396
- });
397
- }
398
-
399
- async function createPack(
400
- principal: Principal,
401
- repoId: RepoId,
402
- ref: string,
403
- ): Promise<{ pack: Uint8Array; commitSha: string; ref: string }> {
404
- gateAccess(principal, repoId, ref, "createPack");
405
- const { pack, commitSha } = await createDeployPack(repoDir(repoId), ref);
406
- return { pack, commitSha, ref };
407
- }
408
-
409
- async function resolveRef(
410
- principal: Principal,
411
- repoId: RepoId,
412
- ref: string,
413
- ): Promise<string | null> {
414
- gateAccess(principal, repoId, ref, "resolveRef");
415
- return resolveRefSha(repoDir(repoId), ref);
416
- }
417
-
418
- return {
419
- initRepo,
420
- writeTree,
421
- receivePack,
422
- createPack,
423
- resolveRef,
424
- listRefs,
425
- resolveHead,
426
- getRepoDir,
427
- };
428
- }
@@ -1,253 +0,0 @@
1
- import { type } from "arktype";
2
- import { RepoAction as RepoActionSchema } from "@intx/types/sidecar";
3
- import type { RepoKind, RepoId, RepoAction } from "@intx/types/sidecar";
4
-
5
- export type { RepoKind, RepoId, RepoAction };
6
-
7
- /**
8
- * arktype validator for the `user` principal variant. The substrate
9
- * only requires the `kind` discriminant; kind handlers that accept
10
- * user-token-authenticated requests rely on this shape to
11
- * cross-check the pre-resolved authz verdict against the bearer
12
- * token's claims. The validator is exported alongside the type so
13
- * handlers can call it for a structural narrow without re-declaring
14
- * the shape.
15
- *
16
- * Field semantics:
17
- * - `authz`: the pre-resolved grant verdict from the route layer.
18
- * The kind handler does NOT re-query the grant store; it only
19
- * sanity-checks that the verdict targets the right resource and
20
- * grant verb, then defers to `effect`.
21
- * - `tokenClaims`: the bearer-token's scope. The kind handler
22
- * verifies the requested `(ref, action)` falls inside this scope
23
- * synchronously (`actions.includes(action)`,
24
- * `glob.match(refPattern, ref)`, `Date.now() < expiresAt`).
25
- */
26
- export const UserPrincipal = type({
27
- kind: "'user'",
28
- principalId: "string",
29
- tenantId: "string",
30
- authz: {
31
- effect: "'allow' | 'deny'",
32
- resource: "string",
33
- grantVerb: "string",
34
- },
35
- tokenClaims: {
36
- refPattern: "string",
37
- actions: RepoActionSchema.array(),
38
- expiresAt: "number",
39
- },
40
- });
41
-
42
- export type UserPrincipal = typeof UserPrincipal.infer;
43
-
44
- /**
45
- * Regex defining the shape of a valid `RepoId.id`. The substrate
46
- * validates against this at every public operation and throws an Error
47
- * prefixed with `"repo_id_invalid: "` on mismatch.
48
- */
49
- export const SAFE_REPO_ID = /^[a-zA-Z0-9_-]+$/;
50
-
51
- /**
52
- * Principal is a discriminated-union extension point. The substrate
53
- * requires only the `kind` discriminant; concrete principal shapes live
54
- * in kind-handler packages and are narrowed via `principal.kind === "..."`
55
- * checks plus arktype validation in the handler. No index signature is
56
- * declared here so that handlers do not need `as Type` casts to access
57
- * their own fields.
58
- */
59
- export type Principal = { readonly kind: string };
60
-
61
- /**
62
- * Authorization callback supplied to the repo-store. Called once per
63
- * substrate operation that requires gating (`writeTree`, `receivePack`,
64
- * `createPack`, `resolveRef`, plus the bulk-read variants below). The
65
- * substrate translates an `allowed: false` verdict into a thrown Error
66
- * prefixed with `"authorize_denied: "` carrying the supplied reason.
67
- *
68
- * The substrate passes the literal string `"*"` as `ref` when it calls
69
- * the authorize hook on behalf of the bulk-read methods `listRefs` and
70
- * `resolveHead`. Both methods enumerate refs across the whole repo and
71
- * have no single ref to feed into a per-ref claim check; the sentinel
72
- * lets kind handlers recognise the call and skip the per-ref
73
- * `refPattern` match while still gating on `action` (always
74
- * `"resolveRef"` for the bulk case) and expiry. Per-ref refPattern
75
- * filtering for the response payload is the responsibility of the
76
- * caller (the advertise-refs layer), not the authorize hook.
77
- */
78
- export type AuthorizeFn = (
79
- principal: Principal,
80
- repoId: RepoId,
81
- ref: string,
82
- action: RepoAction,
83
- ) => { allowed: true } | { allowed: false; reason: string };
84
-
85
- export type ValidatePushResult = { ok: true } | { ok: false; reason: string };
86
-
87
- /**
88
- * Per-call options for `initRepo`. Currently a single override —
89
- * `gitignore` — that overrides the body written to `.gitignore` in
90
- * the genesis tree. When omitted, the substrate's default body is
91
- * used. The asset REST handler supplies a richer body that includes
92
- * OS/editor cruft, common build output, and `keys/`.
93
- */
94
- export type InitRepoOpts = {
95
- gitignore?: string;
96
- };
97
-
98
- /**
99
- * A single ref entry returned by `RepoStore.listRefs`. `name` is the
100
- * fully-qualified ref name (`refs/heads/main`, `refs/tags/v1`, ...);
101
- * `sha` is the SHA-1 the ref currently resolves to.
102
- */
103
- export type RefEntry = {
104
- readonly name: string;
105
- readonly sha: string;
106
- };
107
-
108
- export type TreeContent = {
109
- /**
110
- * Map of repo-relative path to file contents. Each entry is written
111
- * to the working tree and staged before commit.
112
- */
113
- files: Record<string, string | Uint8Array>;
114
- /**
115
- * Optional directory-subtree prefix to clear before staging. When
116
- * set, every tracked path beginning with this prefix is removed
117
- * from the git index and the corresponding directory on disk is
118
- * deleted before `files` is written. Must end with `/` and must
119
- * not contain `..` or absolute path components. When unset,
120
- * writeTree is purely additive.
121
- */
122
- clearPrefix?: string;
123
- /** Commit message for the resulting commit. */
124
- message: string;
125
- };
126
-
127
- export interface KindHandler {
128
- kind: RepoKind;
129
- /**
130
- * On-disk directory under `dataDir` for repos of this kind. Allows
131
- * each kind to declare its own layout (e.g. "agents") so the
132
- * substrate does not hard-code a `<kind>/<id>` path.
133
- */
134
- directoryPrefix: string;
135
- /**
136
- * Inspect the prospective commit's tree before the ref is
137
- * advanced. Return `{ ok: false, reason }` to reject the write.
138
- * The substrate translates rejection into a thrown Error whose
139
- * message begins with `"path_violation: "`.
140
- *
141
- * Runs on every `receivePack` and every `writeTree` independently
142
- * of the authorize verdict: authorize gates access, validatePush
143
- * enforces content rules.
144
- *
145
- * `topLevelTreePaths` lists the names directly under the tree
146
- * root. `readBlob` reads any blob in the tree by repo-root-relative
147
- * POSIX path (e.g. `greet/SKILL.md`). Handlers that only need
148
- * path-level checks can ignore `readBlob`.
149
- */
150
- validatePush: (args: {
151
- repoId: RepoId;
152
- ref: string;
153
- topLevelTreePaths: string[];
154
- readBlob: (path: string) => Promise<Uint8Array>;
155
- }) => Promise<ValidatePushResult> | ValidatePushResult;
156
- /**
157
- * Fired after a successful ref update from any operation. `oldSha`
158
- * is `null` when the ref did not exist before the update.
159
- */
160
- onRefUpdated: (args: {
161
- repoId: RepoId;
162
- ref: string;
163
- oldSha: string | null;
164
- newSha: string;
165
- }) => Promise<void> | void;
166
- }
167
-
168
- export interface RepoStore {
169
- /**
170
- * Bookkeeping primitive. Idempotent. Creates the repo directory
171
- * and initializes git when not already present. Not gated by
172
- * `authorize`: the only state it can produce is an empty repo, so
173
- * the higher-level question of who may mint a new `<kind>/<id>`
174
- * lives at the caller. The substrate also calls `initRepo`
175
- * internally from `writeTree` and `receivePack`, so first-touch
176
- * operations succeed without an explicit init call.
177
- */
178
- initRepo(repoId: RepoId, opts?: InitRepoOpts): Promise<void>;
179
- writeTree(
180
- principal: Principal,
181
- repoId: RepoId,
182
- ref: string,
183
- content: TreeContent,
184
- ): Promise<{ commitSha: string }>;
185
- /**
186
- * Receive a packfile and advance `ref` to `commitSha`.
187
- *
188
- * `expectedOldSha` is a compare-and-set guard the substrate runs
189
- * under the per-repo lock. Pass a SHA string to require the ref
190
- * currently points there; pass `null` to require the ref does not
191
- * yet exist. On mismatch the call throws with a `non_fast_forward:`
192
- * prefix and leaves the ref untouched.
193
- *
194
- * Callers that do not have the old SHA in hand should resolve it
195
- * via `resolveRef` first; the substrate exposes no force-write
196
- * mode because silently overwriting a losing concurrent update is
197
- * never the right behavior.
198
- */
199
- receivePack(
200
- principal: Principal,
201
- repoId: RepoId,
202
- ref: string,
203
- pack: Uint8Array,
204
- commitSha: string,
205
- expectedOldSha: string | null,
206
- ): Promise<void>;
207
- createPack(
208
- principal: Principal,
209
- repoId: RepoId,
210
- ref: string,
211
- ): Promise<{ pack: Uint8Array; commitSha: string; ref: string }>;
212
- resolveRef(
213
- principal: Principal,
214
- repoId: RepoId,
215
- ref: string,
216
- ): Promise<string | null>;
217
- /**
218
- * Enumerate the repo's refs (branches and tags), lexicographically
219
- * sorted by name. The principal is gated under the same
220
- * `resolveRef` action that `resolveRef` itself enforces — the
221
- * substrate does not duplicate the check on a per-ref basis. When
222
- * the on-disk repo does not yet exist (the bookkeeping primitive
223
- * `initRepo` has never been called), the result is the empty list.
224
- */
225
- listRefs(principal: Principal, repoId: RepoId): Promise<RefEntry[]>;
226
- /**
227
- * Resolve HEAD into the ref it symbolically points at plus the SHA
228
- * that ref currently resolves to. The principal is gated under the
229
- * same `resolveRef` action that `resolveRef` and `listRefs`
230
- * enforce. Returns `null` when:
231
- * - The on-disk repo does not yet exist (mirrors `listRefs`'s
232
- * empty-list contract for uninitialised repos).
233
- * - HEAD is detached (no symbolic target).
234
- * - HEAD's symbolic target does not resolve (unborn ref).
235
- * The smart-HTTP advertise layer uses the result to emit
236
- * `symref=HEAD:<target>` so stock `git clone` lands on a real
237
- * branch instead of leaving the working tree unborn.
238
- */
239
- resolveHead(
240
- principal: Principal,
241
- repoId: RepoId,
242
- ): Promise<{ symbolicTarget: string; sha: string } | null>;
243
- /**
244
- * Synchronously return the on-disk directory backing the repo.
245
- * The path is the result of composing the substrate's `dataDir`,
246
- * the kind handler's `directoryPrefix`, and the validated
247
- * `repoId.id`. This carries no authorize gate: it is a pure path
248
- * computation. Consumers of the path (the smart-HTTP wire
249
- * handlers) remain authorize-gated through the substrate methods
250
- * they reach into for ref-listing and pack negotiation.
251
- */
252
- getRepoDir(repoId: RepoId): string;
253
- }