@intx/hub-sessions 0.2.2 → 0.4.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 (87) hide show
  1. package/README.md +3 -5
  2. package/dist/agent-repo.d.ts +23 -7
  3. package/dist/agent-repo.js +19 -6
  4. package/dist/agent-state-kind.js +18 -63
  5. package/dist/asset-service.d.ts +1 -20
  6. package/dist/asset-service.js +19 -97
  7. package/dist/committed-source-tree.d.ts +10 -0
  8. package/dist/committed-source-tree.js +35 -0
  9. package/dist/credential-push.d.ts +52 -7
  10. package/dist/credential-push.js +178 -22
  11. package/dist/event-collector-registry.d.ts +3 -2
  12. package/dist/event-collector-registry.js +42 -13
  13. package/dist/event-collector.d.ts +12 -2
  14. package/dist/event-collector.js +45 -4
  15. package/dist/hub-session-lookups.d.ts +125 -7
  16. package/dist/hub-session-lookups.js +541 -86
  17. package/dist/hub-session-orchestrator.d.ts +2 -3
  18. package/dist/hub-session-orchestrator.js +22 -56
  19. package/dist/index.d.ts +19 -9
  20. package/dist/index.js +16 -7
  21. package/dist/reconciliation-scheduler.d.ts +14 -0
  22. package/dist/reconciliation-scheduler.js +55 -0
  23. package/dist/repo-store/index.d.ts +2 -1
  24. package/dist/repo-store/index.js +1 -0
  25. package/dist/repo-store/store.d.ts +1 -1
  26. package/dist/repo-store/store.js +138 -1
  27. package/dist/repo-store/subscribe-kind.d.ts +6 -3
  28. package/dist/repo-store/subscribe-kind.js +42 -77
  29. package/dist/repo-store/types.d.ts +94 -6
  30. package/dist/repo-store/user-principal-gate.d.ts +26 -0
  31. package/dist/repo-store/user-principal-gate.js +78 -0
  32. package/dist/session-service.d.ts +252 -126
  33. package/dist/session-service.js +851 -624
  34. package/dist/sidecar-allocation/capability-policy.d.ts +27 -0
  35. package/dist/sidecar-allocation/capability-policy.js +124 -0
  36. package/dist/sidecar-allocation/contracts.d.ts +101 -0
  37. package/dist/sidecar-allocation/contracts.js +26 -0
  38. package/dist/sidecar-allocation/index.d.ts +5 -0
  39. package/dist/sidecar-allocation/index.js +4 -0
  40. package/dist/sidecar-allocation/operation.d.ts +10 -0
  41. package/dist/sidecar-allocation/operation.js +54 -0
  42. package/dist/sidecar-allocation/plugin-registry.d.ts +24 -0
  43. package/dist/sidecar-allocation/plugin-registry.js +61 -0
  44. package/dist/sidecar-allocation/reconciler.d.ts +54 -0
  45. package/dist/sidecar-allocation/reconciler.js +825 -0
  46. package/dist/skill-kind.js +12 -62
  47. package/dist/substrate.d.ts +3 -3
  48. package/dist/substrate.js +1 -1
  49. package/dist/workflow-allocation-service.d.ts +64 -0
  50. package/dist/workflow-allocation-service.js +554 -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 +42 -0
  56. package/dist/workflow-dispatch-service.js +209 -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 +29 -1
  60. package/dist/workflow-kind.js +143 -139
  61. package/dist/workflow-probe-gate.d.ts +286 -0
  62. package/dist/workflow-probe-gate.js +382 -0
  63. package/dist/workflow-run-kind.d.ts +239 -32
  64. package/dist/workflow-run-kind.js +874 -188
  65. package/dist/workflow-run-reader.d.ts +1 -1
  66. package/dist/workflow-run-reader.js +3 -7
  67. package/dist/workflow-run-restore.d.ts +16 -0
  68. package/dist/workflow-run-restore.js +30 -0
  69. package/dist/workflow-source-closure.d.ts +35 -0
  70. package/dist/workflow-source-closure.js +342 -0
  71. package/dist/workflow-source-pins.d.ts +8 -0
  72. package/dist/workflow-source-pins.js +14 -0
  73. package/dist/ws/index.d.ts +3 -3
  74. package/dist/ws/index.js +2 -2
  75. package/dist/ws/pending-tracker.d.ts +93 -0
  76. package/dist/ws/pending-tracker.js +132 -0
  77. package/dist/ws/sidecar-events.d.ts +139 -37
  78. package/dist/ws/sidecar-events.js +2 -2
  79. package/dist/ws/sidecar-handler.d.ts +218 -60
  80. package/dist/ws/sidecar-handler.js +1713 -732
  81. package/dist/ws/sidecar-handler.test-helpers.d.ts +38 -0
  82. package/dist/ws/sidecar-handler.test-helpers.js +95 -0
  83. package/dist/ws/sidecar-token-authenticator.d.ts +3 -1
  84. package/dist/ws/sidecar-token-authenticator.js +78 -7
  85. package/package.json +14 -13
  86. package/dist/available-skills-stanza.d.ts +0 -21
  87. package/dist/available-skills-stanza.js +0 -32
@@ -1,17 +1,5 @@
1
- import fs from "node:fs";
2
- import git from "isomorphic-git";
3
1
  import { type } from "arktype";
4
- import { hasCode } from "@intx/types";
5
- /**
6
- * Path-layout constants for the workflow-run repo's event log. The
7
- * workflow-run kind handler commits one JSON blob per event under
8
- * `runs/<runId>/events/<seq>.json` (the layout decided in the
9
- * pre-build interface decisions for the workflow-run repo). The
10
- * `subscribeKind` helper owns this layout so the substrate `subscribe`
11
- * stays generic and free of workflow-event vocabulary.
12
- */
13
- const RUNS_PREFIX = "runs";
14
- const EVENTS_DIR = "events";
2
+ import { requireEventSeq, WORKFLOW_RUN_EVENTS_DIR, WORKFLOW_RUN_RUNS_PREFIX, } from "../workflow-run-kind.js";
15
3
  /**
16
4
  * Substrate envelope shape. Imported textually rather than from
17
5
  * `types.ts` so this helper has a local, validator-checked view of
@@ -24,11 +12,6 @@ const SubstrateEvent = type({
24
12
  "oldSha?": "string | null",
25
13
  newSha: "string",
26
14
  });
27
- /**
28
- * Per-event filename shape. Filenames are `<seq>.json` under
29
- * `runs/<runId>/events/`. `<seq>` is a non-negative decimal integer.
30
- */
31
- const EVENT_FILENAME_RE = /^(0|[1-9][0-9]*)\.json$/;
32
15
  const KindEnvelope = type({
33
16
  type: "string",
34
17
  });
@@ -41,9 +24,12 @@ const KindEnvelope = type({
41
24
  * kinds filter against the inner `type` discriminator, and yields one
42
25
  * `{ seq, event }` entry per matching blob.
43
26
  *
44
- * The path layout is owned here, not in the substrate: the substrate
45
- * `subscribe` is a generic ref-tail primitive and knows nothing about
46
- * workflow-runs.
27
+ * The path-layout vocabulary (the `runs/`/`events/` prefixes and the
28
+ * `<seq>.json` filename shape) lives in the workflow-run kind handler,
29
+ * which is the authority for what may land under this prefix; this
30
+ * helper imports it rather than re-encoding it. The substrate
31
+ * `subscribe` it wraps is a generic ref-tail primitive and knows
32
+ * nothing about workflow-runs.
47
33
  *
48
34
  * Diff scope: the helper enumerates event blobs present in the new
49
35
  * commit but absent from the old commit. A commit that does not add
@@ -53,7 +39,6 @@ const KindEnvelope = type({
53
39
  */
54
40
  export async function* subscribeKind(store, principal, repoId, ref, validator, opts) {
55
41
  const kindsAllowed = new Set(opts.kinds);
56
- const dir = store.getRepoDir(repoId);
57
42
  const subscribeOpts = {
58
43
  signal: opts.signal,
59
44
  from: opts.from,
@@ -67,9 +52,17 @@ export async function* subscribeKind(store, principal, repoId, ref, validator, o
67
52
  if (envelope instanceof type.errors) {
68
53
  throw new Error(`subscribe_kind_envelope_invalid: ${envelope.summary}`);
69
54
  }
70
- const candidates = await collectAddedEventBlobs(dir, envelope.oldSha ?? null, envelope.newSha);
55
+ const newReads = await store.openCommittedReadsAtCommit(principal, repoId, envelope.newSha);
56
+ // A commit a concurrent GC pruned between the ref-update event and
57
+ // this read yields nothing; the substrate surfaces that as `null`.
58
+ if (newReads === null)
59
+ continue;
60
+ const oldReads = envelope.oldSha === undefined || envelope.oldSha === null
61
+ ? null
62
+ : await store.openCommittedReadsAtCommit(principal, repoId, envelope.oldSha);
63
+ const candidates = await collectAddedEventBlobs(newReads, oldReads);
71
64
  for (const candidate of candidates) {
72
- const raw = await readBlobAtCommit(dir, envelope.newSha, candidate.blobPath);
65
+ const raw = await newReads.readBlobByOid(candidate.oid);
73
66
  let parsedJson;
74
67
  try {
75
68
  parsedJson = JSON.parse(new TextDecoder().decode(raw));
@@ -94,20 +87,22 @@ export async function* subscribeKind(store, principal, repoId, ref, validator, o
94
87
  }
95
88
  }
96
89
  /**
97
- * Walk the `runs/<runId>/events/` subtree at `newSha` and at `oldSha`
98
- * (when present) and return the set of event candidates present in
99
- * the new commit but not in the old. Filenames that fail the
100
- * `<seq>.json` shape are surfaced as errors rather than silently
101
- * skipped the workflow-run kind handler's validatePush is the
102
- * authority for what may land under this prefix, so an unexpected
103
- * shape here is a substrate-side invariant violation.
90
+ * Diff the `runs/<runId>/events/` subtree between the new and old commit
91
+ * reads and return the event candidates present in the new commit but
92
+ * not the old. `oldReads` is `null` for the ref's first commit (no prior
93
+ * tip) or when the prior commit is no longer in the object store, in
94
+ * which case every event in the new commit is treated as added.
95
+ * Filenames that fail the `<seq>.json` shape are surfaced as errors
96
+ * rather than silently skipped — the workflow-run kind handler's
97
+ * validatePush is the authority for what may land under this prefix, so
98
+ * an unexpected shape here is a substrate-side invariant violation.
104
99
  */
105
- async function collectAddedEventBlobs(dir, oldSha, newSha) {
106
- const newSet = await enumerateEventBlobs(dir, newSha);
107
- if (oldSha === null) {
100
+ async function collectAddedEventBlobs(newReads, oldReads) {
101
+ const newSet = await enumerateEventBlobs(newReads);
102
+ if (oldReads === null) {
108
103
  return [...newSet.values()].sort(byRunThenSeq);
109
104
  }
110
- const oldSet = await enumerateEventBlobs(dir, oldSha);
105
+ const oldSet = await enumerateEventBlobs(oldReads);
111
106
  const out = [];
112
107
  for (const [key, candidate] of newSet) {
113
108
  if (oldSet.has(key))
@@ -124,56 +119,26 @@ function byRunThenSeq(a, b) {
124
119
  return 1;
125
120
  return a.seq - b.seq;
126
121
  }
127
- async function enumerateEventBlobs(dir, commitOid) {
122
+ async function enumerateEventBlobs(reads) {
128
123
  const out = new Map();
129
- let commit;
130
- try {
131
- commit = await git.readCommit({ fs, dir, oid: commitOid });
132
- }
133
- catch (err) {
134
- if (hasCode(err) && err.code === "NotFoundError")
135
- return out;
136
- throw err;
137
- }
138
- const runsOid = await lookupSubtree(dir, commit.commit.tree, RUNS_PREFIX);
139
- if (runsOid === null)
140
- return out;
141
- const { tree: runs } = await git.readTree({ fs, dir, oid: runsOid });
124
+ const runs = await reads.listDir(WORKFLOW_RUN_RUNS_PREFIX);
142
125
  for (const runEntry of runs) {
143
126
  if (runEntry.type !== "tree")
144
127
  continue;
145
- const runId = runEntry.path;
146
- const eventsOid = await lookupSubtree(dir, runEntry.oid, EVENTS_DIR);
147
- if (eventsOid === null)
148
- continue;
149
- const { tree: events } = await git.readTree({ fs, dir, oid: eventsOid });
128
+ const runId = runEntry.name;
129
+ const events = await reads.listDir(`${WORKFLOW_RUN_RUNS_PREFIX}/${runId}/${WORKFLOW_RUN_EVENTS_DIR}`);
150
130
  for (const blob of events) {
151
131
  if (blob.type !== "blob")
152
132
  continue;
153
- const match = EVENT_FILENAME_RE.exec(blob.path);
154
- if (match === null) {
155
- throw new Error(`subscribe_kind_unexpected_event_filename: ${RUNS_PREFIX}/${runId}/${EVENTS_DIR}/${blob.path}`);
156
- }
157
- const seqStr = match[1];
158
- if (seqStr === undefined)
159
- throw new Error("unreachable");
160
- const seq = Number.parseInt(seqStr, 10);
161
- const blobPath = `${RUNS_PREFIX}/${runId}/${EVENTS_DIR}/${blob.path}`;
162
- out.set(`${runId}/${blob.path}`, { seq, runId, blobPath });
133
+ const blobPath = `${WORKFLOW_RUN_RUNS_PREFIX}/${runId}/${WORKFLOW_RUN_EVENTS_DIR}/${blob.name}`;
134
+ const seq = requireEventSeq(blob.name, blobPath);
135
+ out.set(`${runId}/${blob.name}`, {
136
+ seq,
137
+ runId,
138
+ blobPath,
139
+ oid: blob.oid,
140
+ });
163
141
  }
164
142
  }
165
143
  return out;
166
144
  }
167
- async function lookupSubtree(dir, parentTreeOid, name) {
168
- const { tree } = await git.readTree({ fs, dir, oid: parentTreeOid });
169
- const entry = tree.find((e) => e.path === name);
170
- if (entry === undefined)
171
- return null;
172
- if (entry.type !== "tree")
173
- return null;
174
- return entry.oid;
175
- }
176
- async function readBlobAtCommit(dir, commitOid, filepath) {
177
- const { blob } = await git.readBlob({ fs, dir, oid: commitOid, filepath });
178
- return blob;
179
- }
@@ -80,14 +80,18 @@ export type AuthorizeFn = (principal: Principal, repoId: RepoId, ref: string, ac
80
80
  * commit under validation -- present in the prospective tree and absent from
81
81
  * the prior tree. The kind handler authoritatively detects this during its
82
82
  * validation walk and surfaces it so callers do not re-derive terminal-ness
83
- * by sniffing committed path shapes. `terminalEventJson` carries the raw bytes
84
- * of the terminal event blob so a caller can reconstruct the event without a
85
- * second read. A commit that carries an already-terminal run forward
86
- * unchanged (e.g. a later compaction commit) is NOT newly terminal and does
87
- * not appear here.
83
+ * by sniffing committed path shapes. `status` is the terminal run state the
84
+ * terminal event maps to, matching the `workflow_run.status` vocabulary, so a
85
+ * caller flips the run's row without re-parsing the event type.
86
+ * `terminalEventJson` carries the raw bytes of the terminal event blob so a
87
+ * caller that needs the full event (the supervisor's terminal-write broadcast)
88
+ * can reconstruct it without a second read. A commit that carries an
89
+ * already-terminal run forward unchanged (e.g. a later compaction commit) is
90
+ * NOT newly terminal and does not appear here.
88
91
  */
89
92
  export type NewlyTerminalRun = {
90
93
  runId: string;
94
+ status: "completed" | "failed" | "cancelled";
91
95
  terminalEventJson: string;
92
96
  };
93
97
  export type ValidatePushResult = {
@@ -315,6 +319,48 @@ export interface KindHandler {
315
319
  newSha: string;
316
320
  }) => Promise<void> | void;
317
321
  }
322
+ /**
323
+ * A single child entry returned by `CommittedReads.listDir`. `name` is
324
+ * the entry's own path segment (no parent prefix); `oid` is its git
325
+ * object id; `type` is the git tree-entry kind. `oid` lets a consumer
326
+ * read a blob's bytes via `readBlobByOid` without re-resolving the path,
327
+ * and lets it prove a subtree byte-unchanged by OID equality.
328
+ */
329
+ export type CommittedTreeEntry = {
330
+ readonly name: string;
331
+ readonly oid: string;
332
+ readonly type: "blob" | "tree" | "commit";
333
+ };
334
+ /**
335
+ * Cache-backed reads pinned to the commit a ref resolved to at the
336
+ * moment `openCommittedReads` was called. Every read resolves against
337
+ * the git object store, never the materialized working tree, so a
338
+ * consumer observes committed state even when the on-disk checkout lags
339
+ * the committed tree. The pin is fixed at open time: a concurrent commit
340
+ * that advances the ref afterwards does not shift the reads, so an
341
+ * enumerate-then-read sequence sees a single coherent snapshot.
342
+ *
343
+ * `listDir` returns the direct children of a repo-root-relative POSIX
344
+ * directory path (no leading or trailing slash; the empty string lists
345
+ * the root). A path that is absent, or resolves to a non-tree, lists as
346
+ * the empty array — mirroring the prior-tree closures the substrate
347
+ * hands a kind handler. `readBlobByOid` reads a blob's bytes by its
348
+ * object id; a read fault surfaces as a thrown error rather than an
349
+ * empty result so a consumer cannot silently degrade a missing object
350
+ * into an absent event.
351
+ */
352
+ export type CommittedReads = {
353
+ listDir(relPath: string): Promise<CommittedTreeEntry[]>;
354
+ readBlobByOid(oid: string): Promise<Uint8Array>;
355
+ /**
356
+ * The git tree object id of the subtree at `relPath` (the empty string
357
+ * or "." is the commit's root tree), or `null` when `relPath` is absent
358
+ * or resolves to a non-tree. This is the content identity a source
359
+ * closure freezes for a package materialized from the tree: the sidecar
360
+ * re-checks the checked-out subtree against it.
361
+ */
362
+ treeOid(relPath: string): Promise<string | null>;
363
+ };
318
364
  export interface RepoStore {
319
365
  /**
320
366
  * Bookkeeping primitive. Idempotent. Creates the repo directory
@@ -368,8 +414,16 @@ export interface RepoStore {
368
414
  * via `resolveRef` first; the substrate exposes no force-write
369
415
  * mode because silently overwriting a losing concurrent update is
370
416
  * never the right behavior.
417
+ *
418
+ * Returns the runs the received pack drove to a terminal event,
419
+ * aggregated across every commit it carried. The kind handler
420
+ * detects terminal-ness authoritatively during validation; the
421
+ * substrate forwards it so a caller can react (flip the run's DB
422
+ * row, deactivate its principal) without re-deriving terminal-ness
423
+ * from the committed path shape. Empty for kinds and packs that
424
+ * produce none.
371
425
  */
372
- receivePack(principal: Principal, repoId: RepoId, ref: string, pack: Uint8Array, commitSha: string, expectedOldSha: string | null): Promise<void>;
426
+ receivePack(principal: Principal, repoId: RepoId, ref: string, pack: Uint8Array, commitSha: string, expectedOldSha: string | null): Promise<NewlyTerminalRun[]>;
373
427
  createPack(principal: Principal, repoId: RepoId, ref: string): Promise<{
374
428
  pack: Uint8Array;
375
429
  commitSha: string;
@@ -425,6 +479,40 @@ export interface RepoStore {
425
479
  * they reach into for ref-listing and pack negotiation.
426
480
  */
427
481
  getRepoDir(repoId: RepoId): string;
482
+ /**
483
+ * Open cache-backed reads of the committed tree at `ref`'s tip. The
484
+ * ref is resolved once, at call time, and every read the returned
485
+ * handle serves is pinned to that commit and resolves through the git
486
+ * object store — not the materialized working tree `getRepoDir` points
487
+ * at. A consumer that must observe committed state (e.g. start-time
488
+ * recovery reconstructing a ledger from the persisted log) reads
489
+ * through this rather than the working tree, which a non-atomic
490
+ * post-commit materialization can leave lagging on a contended
491
+ * filesystem.
492
+ *
493
+ * Gated under the same `resolveRef` action as `resolveRef` / `listRefs`
494
+ * / `subscribe`. Returns `null` when the repo does not yet exist
495
+ * (mirrors `listRefs`'s empty-list contract for uninitialised repos)
496
+ * or when `ref` does not resolve to a commit.
497
+ */
498
+ openCommittedReads(principal: Principal, repoId: RepoId, ref: string): Promise<CommittedReads | null>;
499
+ /**
500
+ * Open cache-backed reads of the committed tree at an explicit commit,
501
+ * the by-SHA counterpart of `openCommittedReads`. A consumer that
502
+ * already holds a commit id — e.g. the `newSha`/`oldSha` of a
503
+ * ref-update event it is diffing — reads that exact commit through
504
+ * this, even after the ref has advanced past it. Every read the handle
505
+ * serves resolves through the git object store, pinned to `commitSha`.
506
+ *
507
+ * Gated under the same `resolveRef` action as `openCommittedReads`.
508
+ * `commitSha` is validated at the boundary: a malformed SHA throws
509
+ * `commit_sha_invalid`. Returns `null` when the repo does not yet exist
510
+ * or when `commitSha` names no commit in the object store (a commit a
511
+ * concurrent GC pruned between the caller learning of it and reading
512
+ * it), so a caller diffing a possibly-vanished commit gets an empty
513
+ * view rather than a mid-walk throw.
514
+ */
515
+ openCommittedReadsAtCommit(principal: Principal, repoId: RepoId, commitSha: string): Promise<CommittedReads | null>;
428
516
  /**
429
517
  * Tail a ref's commit log. Returns an async iterator that emits
430
518
  * `{ seq, event }` entries: one per commit on the ref. `seq` is
@@ -0,0 +1,26 @@
1
+ import type { RepoAction, RepoId, Principal } from "./types.js";
2
+ export type AuthorizeUserPrincipalArgs = {
3
+ principal: Principal;
4
+ repoId: RepoId;
5
+ ref: string;
6
+ action: RepoAction;
7
+ /**
8
+ * The resource-kind prefix the pre-resolved authz verdict must carry
9
+ * for this kind: the verdict's `resource` is compared against
10
+ * `<resourcePrefix>:<repoId.id>`.
11
+ */
12
+ resourcePrefix: string;
13
+ };
14
+ /**
15
+ * Verdict for a `user` principal performing `action` on `ref` of
16
+ * `repoId`, in the shape the substrate's `AuthorizeFn` contract
17
+ * expects. The caller dispatches on `principal.kind === "user"` first;
18
+ * this function narrows with `UserPrincipal` and applies the full
19
+ * claim/verdict cross-check.
20
+ */
21
+ export declare function authorizeUserPrincipal({ principal, repoId, ref, action, resourcePrefix, }: AuthorizeUserPrincipalArgs): {
22
+ allowed: true;
23
+ } | {
24
+ allowed: false;
25
+ reason: string;
26
+ };
@@ -0,0 +1,78 @@
1
+ // Shared authorization gate for the `user` principal variant, used by
2
+ // every kind handler that accepts user-token-authenticated requests
3
+ // (workflow, skill, agent-state, workflow-run).
4
+ //
5
+ // The route layer has already pre-resolved the grant verdict and
6
+ // attached it as `authz`; the kind handlers do NOT re-query the grant
7
+ // store here. This gate (a) checks the bearer-token's claims bound the
8
+ // requested (ref, action) and have not expired, and (b) sanity-checks
9
+ // that the pre-resolved verdict targets this exact resource and grant
10
+ // verb. Both gates must pass before the verdict's `effect` is honoured.
11
+ //
12
+ // Funnelling every kind through this one gate keeps the security-
13
+ // critical claim/verdict cross-check from drifting between kinds. The
14
+ // only per-kind input is the `resourcePrefix` the verdict's `resource`
15
+ // must carry (`asset:<id>` for the codebase kinds, `agent-state:<id>`,
16
+ // `workflow-run:<id>`).
17
+ import { type } from "arktype";
18
+ import { glob, repoActionToGrantVerb } from "@intx/hub-common";
19
+ import { UserPrincipal } from "./types.js";
20
+ /**
21
+ * Verdict for a `user` principal performing `action` on `ref` of
22
+ * `repoId`, in the shape the substrate's `AuthorizeFn` contract
23
+ * expects. The caller dispatches on `principal.kind === "user"` first;
24
+ * this function narrows with `UserPrincipal` and applies the full
25
+ * claim/verdict cross-check.
26
+ */
27
+ export function authorizeUserPrincipal({ principal, repoId, ref, action, resourcePrefix, }) {
28
+ const parsed = UserPrincipal(principal);
29
+ if (parsed instanceof type.errors) {
30
+ return {
31
+ allowed: false,
32
+ reason: `user principal is malformed: ${parsed.summary}`,
33
+ };
34
+ }
35
+ if (!parsed.tokenClaims.actions.includes(action)) {
36
+ return {
37
+ allowed: false,
38
+ reason: `token does not grant action ${action}`,
39
+ };
40
+ }
41
+ // `ref === "*"` is the substrate's sentinel for the bulk read
42
+ // performed by `listRefs`. Per-ref filtering is the advertise-refs
43
+ // layer's responsibility, so the bulk read is gated on action and
44
+ // expiry alone.
45
+ if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) {
46
+ return {
47
+ allowed: false,
48
+ reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`,
49
+ };
50
+ }
51
+ if (Date.now() >= parsed.tokenClaims.expiresAt) {
52
+ return {
53
+ allowed: false,
54
+ reason: `token expired at ${parsed.tokenClaims.expiresAt}`,
55
+ };
56
+ }
57
+ const expectedResource = `${resourcePrefix}:${repoId.id}`;
58
+ if (parsed.authz.resource !== expectedResource) {
59
+ return {
60
+ allowed: false,
61
+ reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`,
62
+ };
63
+ }
64
+ const expectedGrantVerb = repoActionToGrantVerb(action);
65
+ if (parsed.authz.grantVerb !== expectedGrantVerb) {
66
+ return {
67
+ allowed: false,
68
+ reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`,
69
+ };
70
+ }
71
+ if (parsed.authz.effect === "allow") {
72
+ return { allowed: true };
73
+ }
74
+ return {
75
+ allowed: false,
76
+ reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
77
+ };
78
+ }