@afokapu/atdd-flow 0.3.74 → 0.3.76

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.
@@ -217,7 +217,11 @@ export function createInboxTransport({ root, seat, deliver, batchSize = defaultB
217
217
  };
218
218
  const start = async () => {
219
219
  await reconcile();
220
- try { watcher = fs.watch(pendingDirectory(root, seat), () => { void reconcile(); }); }
220
+ try {
221
+ // Watch a path that exists before the first queued mail, so a newly created queue wakes promptly.
222
+ await mkdir(pendingDirectory(root, seat), { recursive: true });
223
+ watcher = fs.watch(pendingDirectory(root, seat), () => { void reconcile(); });
224
+ }
221
225
  catch { /* Periodic reconciliation recovers unavailable watchers. */ }
222
226
  interval = setInterval(() => { void reconcile(); }, intervalMs);
223
227
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.74",
3
+ "version": "0.3.76",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,19 +22,47 @@ export type ExactSessionAdoptionAuthorization = {
22
22
  target: { herdr_session: string; pane: string; cwd: string };
23
23
  };
24
24
 
25
+ // Ids are path components, so only A- plus a safe charset is accepted; this blocks traversal.
25
26
  const validId = (value: string) => /^A-[A-Za-z0-9_-]+$/.test(value);
27
+ // A blank value can never bind an adoption, so whitespace-only strings count as missing.
26
28
  const nonEmpty = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;
27
- const path = (root: string, id: string) => join(root, ".atdd-flow", "exact-session-adoption-authorizations", `${id}.yaml`);
28
- const hashPath = (root: string, id: string) => join(root, ".atdd-flow", "exact-session-adoption-authorizations", `${id}.sha256`);
29
+ // Records and their digests live side by side in one create-only directory.
30
+ function authorizationDir(root: string) {
31
+ return join(root, ".atdd-flow", "exact-session-adoption-authorizations");
32
+ }
33
+ function path(root: string, id: string) {
34
+ return join(authorizationDir(root), `${id}.yaml`);
35
+ }
36
+ function hashPath(root: string, id: string) {
37
+ return join(authorizationDir(root), `${id}.sha256`);
38
+ }
39
+ // SHA-256 of the exact record bytes; any later edit changes the digest.
29
40
  const digest = (content: string) => createHash("sha256").update(content).digest("hex");
30
41
 
42
+ // The tuple check is split by concern so every predicate stays small and independently readable.
43
+ function validHeader(record: ExactSessionAdoptionAuthorization) {
44
+ return record.schema === authorizationSchema && validId(record.id) && record.issued_by === "operator@desk" && nonEmpty(record.created_at);
45
+ }
46
+ // Seat and Pi session identity must be present.
47
+ function validSubject(record: ExactSessionAdoptionAuthorization) {
48
+ return nonEmpty(record.seat) && nonEmpty(record.pi_session) && nonEmpty(record.pi_session_path);
49
+ }
50
+ // The source Herdr placement must be present.
51
+ function validSource(record: ExactSessionAdoptionAuthorization) {
52
+ return nonEmpty(record.source?.herdr_session) && nonEmpty(record.source?.pane);
53
+ }
54
+ // The target Herdr placement and working directory must be present.
55
+ function validTarget(record: ExactSessionAdoptionAuthorization) {
56
+ return nonEmpty(record.target?.herdr_session) && nonEmpty(record.target?.pane) && nonEmpty(record.target?.cwd);
57
+ }
31
58
  function tuple(record: ExactSessionAdoptionAuthorization) {
32
- return record.schema === authorizationSchema && validId(record.id) && record.issued_by === "operator@desk" && nonEmpty(record.created_at)
33
- && nonEmpty(record.seat) && nonEmpty(record.pi_session) && nonEmpty(record.pi_session_path)
34
- && nonEmpty(record.source?.herdr_session) && nonEmpty(record.source?.pane)
35
- && nonEmpty(record.target?.herdr_session) && nonEmpty(record.target?.pane) && nonEmpty(record.target?.cwd);
59
+ return validHeader(record) && validSubject(record) && validSource(record) && validTarget(record);
36
60
  }
37
61
 
62
+ /**
63
+ * Issue a create-only operator authorization: the record and its digest are written with the `wx` flag,
64
+ * so an existing id can never be overwritten or silently reissued with a different tuple.
65
+ */
38
66
  export async function createExactSessionAdoptionAuthorization(root: string, id: string, args: string[]) {
39
67
  if (!validId(id)) throw new Error("Exact-session adoption authorization id must use A- followed by letters, numbers, underscores, or hyphens.");
40
68
  if (words(args, "--by") !== "operator@desk") throw new Error("Only operator@desk may issue an exact-session adoption authorization.");
@@ -53,40 +81,75 @@ export async function createExactSessionAdoptionAuthorization(root: string, id:
53
81
  process.stdout.write(`${id}\n`);
54
82
  }
55
83
 
56
- function same(record: ExactSessionAdoptionAuthorization, expected: Omit<ExactSessionAdoptionAuthorization, "schema" | "id" | "issued_by" | "created_at">) {
57
- return record.seat === expected.seat && record.pi_session === expected.pi_session && record.pi_session_path === expected.pi_session_path
58
- && record.source.herdr_session === expected.source.herdr_session && record.source.pane === expected.source.pane
59
- && record.target.herdr_session === expected.target.herdr_session && record.target.pane === expected.target.pane && record.target.cwd === expected.target.cwd;
84
+ // The adoption tuple a launch asks to use; the stored record must match it field for field.
85
+ type ExpectedTuple = Omit<ExactSessionAdoptionAuthorization, "schema" | "id" | "issued_by" | "created_at">;
86
+
87
+ // The operator-issued record must bind every element of the adoption tuple, not a subset.
88
+ // Seat and Pi session identity.
89
+ function sameSubject(record: ExactSessionAdoptionAuthorization, expected: ExpectedTuple) {
90
+ return record.seat === expected.seat && record.pi_session === expected.pi_session && record.pi_session_path === expected.pi_session_path;
91
+ }
92
+ // Where the session runs before the move.
93
+ function sameSource(record: ExactSessionAdoptionAuthorization, expected: ExpectedTuple) {
94
+ return record.source.herdr_session === expected.source.herdr_session && record.source.pane === expected.source.pane;
95
+ }
96
+ // Where the session will run after the move, including its working directory.
97
+ function sameTarget(record: ExactSessionAdoptionAuthorization, expected: ExpectedTuple) {
98
+ return record.target.herdr_session === expected.target.herdr_session && record.target.pane === expected.target.pane && record.target.cwd === expected.target.cwd;
99
+ }
100
+ function same(record: ExactSessionAdoptionAuthorization, expected: ExpectedTuple) {
101
+ return sameSubject(record, expected) && sameSource(record, expected) && sameTarget(record, expected);
102
+ }
103
+
104
+ // Narrow an unknown YAML value to a plain object so nested receipt fields can be read without casts.
105
+ const asRecord = (value: unknown): Record<string, unknown> | undefined => (value && typeof value === "object" ? (value as Record<string, unknown>) : undefined);
106
+
107
+ // A receipt names an authorization when relocation.authorization.id equals the id.
108
+ function receiptNames(receipt: unknown, id: string) {
109
+ return asRecord(asRecord(asRecord(receipt)?.relocation)?.authorization)?.id === id;
110
+ }
111
+
112
+ // A receipt that cannot be read or parsed never authorizes reuse, so it counts as not naming the id.
113
+ async function receiptFileNames(file: string, id: string) {
114
+ try { return receiptNames(yaml.parse<unknown>(await readFile(file, "utf8")), id); }
115
+ catch { return false; }
60
116
  }
61
117
 
118
+ // A receipt that already names this authorization id means the one-shot authorization was consumed.
62
119
  async function used(root: string, id: string) {
63
120
  const folder = join(root, ".atdd-flow", "runtime-launch");
64
- try {
65
- const files = await readdir(folder);
66
- for (const file of files.filter((entry) => entry.endsWith(".yaml"))) {
67
- try {
68
- const value = yaml.parse<Record<string, unknown>>(await readFile(join(folder, file), "utf8"));
69
- const authorization = value.relocation && typeof value.relocation === "object" ? (value.relocation as Record<string, unknown>).authorization : undefined;
70
- if (authorization && typeof authorization === "object" && (authorization as Record<string, unknown>).id === id) return true;
71
- } catch { /* Malformed receipts never authorize reuse. */ }
72
- }
73
- } catch { /* No previous receipt means unused. */ }
121
+ let files: string[];
122
+ try { files = await readdir(folder); }
123
+ catch { return false; } // No previous receipt means unused.
124
+ for (const file of files.filter((entry) => entry.endsWith(".yaml"))) {
125
+ if (await receiptFileNames(join(folder, file), id)) return true;
126
+ }
74
127
  return false;
75
128
  }
76
129
 
77
- /** Resolve only a create-only operator record whose complete tuple matches before runtime projection. */
78
- export async function resolveExactSessionAdoptionAuthorization(root: string, id: string, expected: Omit<ExactSessionAdoptionAuthorization, "schema" | "id" | "issued_by" | "created_at">) {
79
- if (!validId(id)) throw new Error("Exact-session adoption requires an immutable operator authorization record id.");
130
+ // Each reader fails with the same operator-facing message the combined resolver always used.
131
+ async function readRecord(root: string, id: string) {
80
132
  let raw: string;
81
133
  try { raw = await readFile(path(root, id), "utf8"); }
82
134
  catch { throw new Error("Exact-session adoption requires an existing immutable operator authorization record."); }
83
- let record: ExactSessionAdoptionAuthorization;
84
- try { record = yaml.parse<ExactSessionAdoptionAuthorization>(raw); }
135
+ try { return { raw, record: yaml.parse<ExactSessionAdoptionAuthorization>(raw) }; }
85
136
  catch { throw new Error("Exact-session adoption requires a well-formed immutable operator authorization record."); }
137
+ }
138
+
139
+ // The digest written next to the record at creation proves the record was never edited afterwards.
140
+ async function readStoredHash(root: string, id: string, raw: string) {
86
141
  let storedHash: string;
87
142
  try { storedHash = (await readFile(hashPath(root, id), "utf8")).trim(); }
88
143
  catch { throw new Error("Exact-session adoption requires a create-only immutable operator authorization record."); }
89
144
  if (!/^[a-f0-9]{64}$/i.test(storedHash) || storedHash !== digest(raw)) throw new Error("Exact-session adoption requires an immutable operator authorization record.");
145
+ return storedHash;
146
+ }
147
+
148
+ /** Resolve only a create-only operator record whose complete tuple matches before runtime projection. */
149
+ export async function resolveExactSessionAdoptionAuthorization(root: string, id: string, expected: ExpectedTuple) {
150
+ if (!validId(id)) throw new Error("Exact-session adoption requires an immutable operator authorization record id.");
151
+ const { raw, record } = await readRecord(root, id);
152
+ const storedHash = await readStoredHash(root, id, raw);
90
153
  if (!tuple(record) || record.id !== id || !same(record, expected)) throw new Error("Exact-session adoption requires an immutable operator authorization record bound to the exact adoption tuple.");
91
154
  if (await used(root, id)) throw new Error("Exact-session adoption authorization record was already used.");
92
155
  return { id, hash: storedHash };