@intx/hub-sessions 0.1.2 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +176 -0
- package/README.md +84 -1
- package/dist/agent-repo.d.ts +89 -0
- package/dist/agent-repo.js +109 -0
- package/dist/agent-state-kind.d.ts +12 -0
- package/dist/agent-state-kind.js +185 -0
- package/dist/asset-service.d.ts +123 -0
- package/dist/asset-service.js +349 -0
- package/dist/available-skills-stanza.d.ts +21 -0
- package/dist/available-skills-stanza.js +32 -0
- package/dist/credential-push.d.ts +32 -0
- package/dist/credential-push.js +85 -0
- package/dist/event-collector-registry.d.ts +20 -0
- package/dist/event-collector-registry.js +115 -0
- package/dist/event-collector.d.ts +39 -0
- package/dist/event-collector.js +357 -0
- package/dist/hub-session-lookups.d.ts +17 -0
- package/dist/hub-session-lookups.js +204 -0
- package/dist/hub-session-orchestrator.d.ts +25 -0
- package/dist/hub-session-orchestrator.js +122 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +16 -0
- package/dist/package-registry-kind.d.ts +70 -0
- package/dist/package-registry-kind.js +260 -0
- package/dist/repo-store/index.d.ts +4 -0
- package/dist/repo-store/index.js +3 -0
- package/dist/repo-store/store.d.ts +41 -0
- package/dist/repo-store/store.js +1692 -0
- package/dist/repo-store/subscribe-kind.d.ts +53 -0
- package/dist/repo-store/subscribe-kind.js +179 -0
- package/dist/repo-store/types.d.ts +483 -0
- package/dist/repo-store/types.js +42 -0
- package/dist/session-service.d.ts +235 -0
- package/dist/session-service.js +997 -0
- package/dist/skill-kind.d.ts +41 -0
- package/dist/skill-kind.js +288 -0
- package/dist/substrate.d.ts +8 -0
- package/dist/substrate.js +21 -0
- package/dist/workflow-kind.d.ts +21 -0
- package/dist/workflow-kind.js +263 -0
- package/dist/workflow-run-event-log.d.ts +21 -0
- package/dist/workflow-run-event-log.js +51 -0
- package/dist/workflow-run-kind.d.ts +326 -0
- package/dist/workflow-run-kind.js +2646 -0
- package/dist/workflow-run-reader.d.ts +47 -0
- package/dist/workflow-run-reader.js +157 -0
- package/dist/ws/index.d.ts +3 -0
- package/dist/ws/index.js +3 -0
- package/dist/ws/sidecar-events.d.ts +134 -0
- package/dist/ws/sidecar-events.js +70 -0
- package/dist/ws/sidecar-handler.d.ts +184 -0
- package/dist/ws/sidecar-handler.js +1603 -0
- package/dist/ws/sidecar-token-authenticator.d.ts +15 -0
- package/dist/ws/sidecar-token-authenticator.js +24 -0
- package/package.json +34 -12
- package/src/agent-repo.test.ts +0 -310
- package/src/agent-repo.ts +0 -165
- package/src/agent-state-kind.test.ts +0 -247
- package/src/agent-state-kind.ts +0 -204
- package/src/asset-service.test.ts +0 -540
- package/src/asset-service.ts +0 -378
- package/src/available-skills-stanza.test.ts +0 -87
- package/src/available-skills-stanza.ts +0 -47
- package/src/credential-push.ts +0 -65
- package/src/event-collector-registry.test.ts +0 -73
- package/src/event-collector-registry.ts +0 -171
- package/src/event-collector.test.ts +0 -1387
- package/src/event-collector.ts +0 -424
- package/src/hub-session-lookups.ts +0 -206
- package/src/hub-session-orchestrator.test.ts +0 -510
- package/src/hub-session-orchestrator.ts +0 -213
- package/src/index.ts +0 -78
- package/src/repo-store/index.ts +0 -15
- package/src/repo-store/store.test.ts +0 -1169
- package/src/repo-store/store.ts +0 -428
- package/src/repo-store/types.ts +0 -253
- package/src/session-service.test.ts +0 -895
- package/src/session-service.ts +0 -464
- package/src/skill-kind.test.ts +0 -599
- package/src/skill-kind.ts +0 -350
- package/src/ws/index.ts +0 -18
- package/src/ws/sidecar-events.test.ts +0 -96
- package/src/ws/sidecar-events.ts +0 -231
- package/src/ws/sidecar-handler.test.ts +0 -2217
- package/src/ws/sidecar-handler.ts +0 -1574
- package/tsconfig.json +0 -4
- package/tsconfig.tsbuildinfo +0 -1
package/src/repo-store/store.ts
DELETED
|
@@ -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
|
-
}
|
package/src/repo-store/types.ts
DELETED
|
@@ -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
|
-
}
|