@sleep2agi/commhub-server 0.9.0-preview.8 → 0.9.0-preview.80

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 (202) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +5 -5
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access-batch.test.ts +210 -0
  9. package/src/agent-access.ts +478 -0
  10. package/src/agent-acl-groups-http.test.ts +247 -0
  11. package/src/agent-acl-http.test.ts +520 -0
  12. package/src/agent-acl-slice3-http.test.ts +267 -0
  13. package/src/agent-groups-perf.test.ts +190 -0
  14. package/src/alias-filter.test.ts +72 -0
  15. package/src/alias-filter.ts +32 -0
  16. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  17. package/src/api-host-supervisors-fallback.test.ts +271 -0
  18. package/src/api-nodes-shape.test.ts +26 -10
  19. package/src/auth-sessions-http.test.ts +250 -0
  20. package/src/auth-validate.test.ts +5 -1
  21. package/src/auth.ts +450 -31
  22. package/src/avatar-validate.test.ts +163 -0
  23. package/src/avatar-validate.ts +103 -0
  24. package/src/blocked-exit-on-reply.test.ts +122 -0
  25. package/src/boot-side-effect.test.ts +124 -0
  26. package/src/commhub-bin-args.test.ts +85 -0
  27. package/src/config-apply-sec1.test.ts +107 -0
  28. package/src/config-apply-validate.test.ts +92 -0
  29. package/src/config-apply-validate.ts +81 -3
  30. package/src/create-node-blocked-gate.test.ts +356 -0
  31. package/src/create-node-tool-schema.test.ts +39 -0
  32. package/src/create-node-validate.test.ts +89 -1
  33. package/src/create-node-validate.ts +89 -7
  34. package/src/create-node-workdir.test.ts +255 -0
  35. package/src/create-node.ts +46 -1
  36. package/src/dashboard-slash-routing-http.test.ts +107 -0
  37. package/src/db-adapter-guard.test.ts +148 -0
  38. package/src/db-adapter-translate.test.ts +128 -0
  39. package/src/db-adapter.ts +323 -87
  40. package/src/db-timestamp.test.ts +38 -0
  41. package/src/db-timestamp.ts +15 -0
  42. package/src/db.ts +805 -33
  43. package/src/delivered-without-push.test.ts +140 -0
  44. package/src/desktop-message-seam.test.ts +150 -0
  45. package/src/desktop-message.test.ts +368 -0
  46. package/src/dm-file-scope-http.test.ts +178 -0
  47. package/src/ext-token-shared.test.ts +60 -0
  48. package/src/ext-validation-regression.test.ts +131 -0
  49. package/src/external-schedule-edits-http.test.ts +321 -0
  50. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  51. package/src/external-schedule-edits.ts +327 -0
  52. package/src/file-download-authz-dev-open.test.ts +135 -0
  53. package/src/file-download-authz.test.ts +404 -0
  54. package/src/file-download-date-bucket.test.ts +436 -0
  55. package/src/file-network-scope-dev-open.test.ts +97 -0
  56. package/src/file-network-scope.test.ts +732 -0
  57. package/src/health-redaction.test.ts +642 -0
  58. package/src/http-gzip.test.ts +94 -0
  59. package/src/http-gzip.ts +110 -0
  60. package/src/hub-timestamp.test.ts +38 -0
  61. package/src/hub-timestamp.ts +27 -0
  62. package/src/human-dm.ts +215 -0
  63. package/src/inbox-count-sse.test.ts +310 -0
  64. package/src/inbox-count.ts +29 -0
  65. package/src/index.ts +15 -2331
  66. package/src/lifecycle-guard.ts +50 -0
  67. package/src/mcp-route-label-http.test.ts +120 -0
  68. package/src/mcp-write-network-resolution.test.ts +429 -0
  69. package/src/member-audit-network-id-http.test.ts +93 -0
  70. package/src/member-presence-http.test.ts +206 -0
  71. package/src/messages-ack-by-agent-http.test.ts +142 -0
  72. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  73. package/src/migrate-sqlite-to-pg.ts +263 -0
  74. package/src/network-name-http.test.ts +75 -0
  75. package/src/network-scope.ts +224 -0
  76. package/src/node-attrs-validate.ts +88 -0
  77. package/src/node-attrs.test.ts +217 -0
  78. package/src/node-create-request-status-http.test.ts +92 -0
  79. package/src/node-files-transport.test.ts +344 -0
  80. package/src/node-id-alias.test.ts +188 -0
  81. package/src/node-lifecycle-controllable.test.ts +64 -0
  82. package/src/node-lifecycle-controllable.ts +51 -0
  83. package/src/node-logs-transport.test.ts +263 -0
  84. package/src/node-request-retention.test.ts +281 -0
  85. package/src/node-request-retention.ts +120 -0
  86. package/src/node-skills-transport.test.ts +209 -0
  87. package/src/node-token-privilege-http.test.ts +149 -0
  88. package/src/observer-avatar-http.test.ts +429 -0
  89. package/src/observer-push.test.ts +252 -0
  90. package/src/package-version-consistency.test.ts +25 -0
  91. package/src/peer-reply-atomic.test.ts +455 -0
  92. package/src/peer-reply-capability.test.ts +70 -0
  93. package/src/pg-worker.ts +87 -0
  94. package/src/push.ts +284 -7
  95. package/src/redact-tokens.test.ts +65 -0
  96. package/src/redact-tokens.ts +40 -0
  97. package/src/rename.ts +7 -2
  98. package/src/reply-target-derivation.test.ts +96 -0
  99. package/src/report-status-host-schema.test.ts +211 -0
  100. package/src/report-status-resume-id-handover.test.ts +150 -0
  101. package/src/require-explicit-test-db.ts +9 -0
  102. package/src/requirement-events-http.test.ts +257 -0
  103. package/src/requirement-events.test.ts +45 -0
  104. package/src/requirement-events.ts +143 -0
  105. package/src/requirement-notify.ts +134 -0
  106. package/src/requirement-participant-notify-http.test.ts +309 -0
  107. package/src/requirement-tag-ops-http.test.ts +266 -0
  108. package/src/requirement-tags.ts +89 -0
  109. package/src/requirements-completed-at-migration.test.ts +70 -0
  110. package/src/requirements-etag-http.test.ts +91 -0
  111. package/src/requirements-http.test.ts +709 -0
  112. package/src/requirements-list-slim-http.test.ts +315 -0
  113. package/src/requirements-mcp.test.ts +163 -0
  114. package/src/requirements-migrate.ts +225 -0
  115. package/src/requirements-people-http.test.ts +114 -0
  116. package/src/requirements-priority-lowest.test.ts +114 -0
  117. package/src/requirements-search-http.test.ts +182 -0
  118. package/src/requirements-search.test.ts +32 -0
  119. package/src/requirements-search.ts +138 -0
  120. package/src/requirements-seq-http.test.ts +193 -0
  121. package/src/requirements-seq-migration.test.ts +63 -0
  122. package/src/requirements-start-date.test.ts +125 -0
  123. package/src/requirements-stats-http.test.ts +288 -0
  124. package/src/requirements-stats.ts +164 -0
  125. package/src/requirements-upgrade-e2e.test.ts +126 -0
  126. package/src/requirements-upgrade-migration.test.ts +98 -0
  127. package/src/requirements.ts +1439 -0
  128. package/src/resolve-port.test.ts +57 -0
  129. package/src/resolve-port.ts +44 -0
  130. package/src/response-charset.test.ts +130 -0
  131. package/src/rest-explicit-columns-http.test.ts +216 -0
  132. package/src/rest-identity.test.ts +129 -0
  133. package/src/rest-identity.ts +100 -0
  134. package/src/rest-projections.ts +68 -0
  135. package/src/rest-write-network-resolution.test.ts +133 -0
  136. package/src/restricted-files.ts +73 -0
  137. package/src/retention.ts +15 -3
  138. package/src/route-callers-http.test.ts +120 -0
  139. package/src/route-timing.test.ts +211 -0
  140. package/src/route-timing.ts +258 -0
  141. package/src/rules-file-duplicate-alias.test.ts +234 -0
  142. package/src/rules-file-session-target.test.ts +207 -0
  143. package/src/rules-file-transport.test.ts +237 -0
  144. package/src/runtime-label.test.ts +22 -0
  145. package/src/runtime-label.ts +21 -0
  146. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  147. package/src/scheduled-reply-unread-http.test.ts +212 -0
  148. package/src/scheduled-run-terminal.test.ts +268 -0
  149. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  150. package/src/scheduled-tasks-http.test.ts +638 -0
  151. package/src/scheduled-tasks.ts +597 -0
  152. package/src/send-dedup-request-id.test.ts +229 -0
  153. package/src/send-reply-agent-warning.test.ts +202 -0
  154. package/src/send-reply-alias-optional.test.ts +131 -0
  155. package/src/send-reply-attachments.test.ts +458 -0
  156. package/src/send_dedup.test.ts +54 -0
  157. package/src/send_dedup.ts +52 -6
  158. package/src/serve-error.test.ts +38 -0
  159. package/src/serve-error.ts +27 -0
  160. package/src/server.ts +4522 -0
  161. package/src/shared/external-schedule-contract.ts +60 -0
  162. package/src/side-thread-command-transport.test.ts +312 -0
  163. package/src/side-thread-command-transport.ts +318 -0
  164. package/src/side-thread-http-integration.test.ts +233 -0
  165. package/src/side-thread-http.ts +504 -0
  166. package/src/side-thread-production.test.ts +72 -0
  167. package/src/side-thread-production.ts +118 -0
  168. package/src/side-thread.test.ts +1067 -0
  169. package/src/side-thread.ts +2499 -0
  170. package/src/skillhub-http.test.ts +108 -0
  171. package/src/sqlite-close-checkpoint.test.ts +78 -0
  172. package/src/start-node.test.ts +109 -0
  173. package/src/status-alias-filter-http.test.ts +106 -0
  174. package/src/status-node-id-filter-http.test.ts +161 -0
  175. package/src/stop-delete-node.test.ts +1164 -0
  176. package/src/task-access-http.test.ts +379 -0
  177. package/src/task-access.ts +307 -0
  178. package/src/task-auth-origin-http.test.ts +91 -0
  179. package/src/task-auth-origin.test.ts +24 -0
  180. package/src/task-auth-origin.ts +7 -0
  181. package/src/task-consumption.test.ts +482 -0
  182. package/src/task-diagnostic-http.test.ts +149 -0
  183. package/src/task-diagnostic.test.ts +115 -0
  184. package/src/task-diagnostic.ts +118 -0
  185. package/src/task-idempotency-mcp.test.ts +142 -0
  186. package/src/task-idempotency.test.ts +60 -0
  187. package/src/task-idempotency.ts +75 -0
  188. package/src/task-lifecycle-watcher.test.ts +204 -0
  189. package/src/task-lifecycle-watcher.ts +49 -0
  190. package/src/task-network-resolution-http.test.ts +141 -0
  191. package/src/tasks-pagination-http.test.ts +141 -0
  192. package/src/tools-list-json-schema.test.ts +66 -0
  193. package/src/tools.ts +3534 -343
  194. package/src/update-provider.test.ts +65 -0
  195. package/src/upload-early-reject-guard.test.ts +155 -0
  196. package/src/uploads-http.test.ts +97 -15
  197. package/src/uploads.test.ts +66 -0
  198. package/src/uploads.ts +138 -5
  199. package/src/user-inbox-by-agent-http.test.ts +161 -0
  200. package/src/user-inbox-netid-notnull.test.ts +122 -0
  201. package/src/user-inbox-read-http.test.ts +223 -0
  202. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * RFC-039 S5 — offline copy of a Hub's SQLite database into an EMPTY
3
+ * PostgreSQL database.
4
+ *
5
+ * commhub-server migrate-to-pg --from <sqlite file> --to <postgres url>
6
+ * [--dry-run] [--i-know-this-is-a-copy]
7
+ *
8
+ * Contract:
9
+ * - offline: no running process may have the source (or its -wal/-shm)
10
+ * open (checked through /proc on Linux). A stopped Hub normally leaves a
11
+ * -wal behind — that is expected, and its contents are included: the
12
+ * source is opened read-only and read through a `VACUUM INTO` snapshot in
13
+ * a private temp dir.
14
+ * - never the live Hub's database: a source whose real path is under
15
+ * ~/.commhub is refused unless --i-know-this-is-a-copy is given.
16
+ * - empty target only: the target's `public` schema must hold no tables,
17
+ * views, sequences or functions. The schema is then built by the Hub's
18
+ * own module graph (no second copy of the DDL).
19
+ * - one transaction: every table is copied and verified — row counts plus
20
+ * a SHA-256 over canonicalised rows — before COMMIT; any mismatch or
21
+ * error rolls everything back.
22
+ * - --dry-run does all of the above, rolls back, and drops what it created,
23
+ * leaving the target as it found it.
24
+ * - the target URL is printed with credentials removed.
25
+ */
26
+ import { Database } from "bun:sqlite";
27
+ import { createHash } from "node:crypto";
28
+ import { existsSync, mkdtempSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { join, resolve, sep } from "node:path";
31
+ import { PgAdapter, redactPgUrl, type DbAdapter } from "./db-adapter.js";
32
+
33
+ export type MigrateOptions = {
34
+ from: string;
35
+ to: string;
36
+ dryRun?: boolean;
37
+ allowCommhubCopy?: boolean;
38
+ home?: string;
39
+ log?: (line: string) => void;
40
+ };
41
+
42
+ export type TableReport = { table: string; rows: number; sha256: string };
43
+ export type MigrateReport = { ok: true; dryRun: boolean; tables: TableReport[] };
44
+
45
+ class DryRunRollback extends Error {}
46
+
47
+ /** Refusals are thrown as MigrateRefused so the CLI can print them without a stack. */
48
+ export class MigrateRefused extends Error {}
49
+
50
+ function refuse(message: string): never {
51
+ throw new MigrateRefused(`[migrate-to-pg] REFUSING: ${message}`);
52
+ }
53
+
54
+ const quoteIdent = (name: string) => `"${name.replace(/"/g, '""')}"`;
55
+
56
+ /** One canonical text per value, identical for bun:sqlite and PgAdapter output. */
57
+ function canonical(value: unknown): unknown {
58
+ if (value === null || value === undefined) return null;
59
+ if (typeof value === "bigint") return `i:${value.toString()}`;
60
+ if (typeof value === "number") return Number.isInteger(value) ? `i:${value}` : `f:${value}`;
61
+ if (value instanceof Uint8Array) return `b:${Buffer.from(value).toString("hex")}`;
62
+ if (typeof value === "string" && /^-?\d+$/.test(value) && value.length > 15) return `i:${value}`; // unsafe int8 comes back as text
63
+ return `s:${String(value)}`;
64
+ }
65
+
66
+ /** Order-independent digest: hash each row, sort the row hashes, hash the list. */
67
+ function tableDigest(rows: Iterable<Record<string, unknown>>, cols: string[]): { rows: number; sha256: string } {
68
+ const rowHashes: string[] = [];
69
+ for (const row of rows) {
70
+ rowHashes.push(createHash("sha256").update(JSON.stringify(cols.map((c) => canonical(row[c])))).digest("hex"));
71
+ }
72
+ rowHashes.sort();
73
+ return { rows: rowHashes.length, sha256: createHash("sha256").update(rowHashes.join("\n")).digest("hex") };
74
+ }
75
+
76
+ function bindValue(value: unknown): unknown {
77
+ if (typeof value === "bigint") {
78
+ const n = Number(value);
79
+ return Number.isSafeInteger(n) ? n : value.toString();
80
+ }
81
+ return value;
82
+ }
83
+
84
+ function checkSource(from: string, home: string, allowCommhubCopy: boolean): string {
85
+ const abs = resolve(from);
86
+ if (!existsSync(abs)) refuse(`source ${abs} does not exist`);
87
+ const real = realpathSync(abs);
88
+ if (!statSync(real).isFile()) refuse(`source ${real} is not a file`);
89
+ const commhubDir = join(home, ".commhub");
90
+ const guarded = existsSync(commhubDir) ? realpathSync(commhubDir) : resolve(commhubDir);
91
+ if ((real === guarded || real.startsWith(guarded + sep)) && !allowCommhubCopy) {
92
+ refuse(`source ${real} is under ${guarded}, the live Hub's directory. Stop the Hub, copy the database elsewhere, ` +
93
+ "and migrate the copy — or pass --i-know-this-is-a-copy if this file already is one");
94
+ }
95
+ const holders = processesHolding([real, `${real}-wal`, `${real}-shm`]);
96
+ if (holders === null) {
97
+ console.warn("[migrate-to-pg] cannot inspect open files on this platform — make sure the Hub using this database is stopped");
98
+ } else if (holders.length) {
99
+ refuse(`${real} is open in running process(es) ${holders.join(", ")} — stop the Hub first`);
100
+ }
101
+ return real;
102
+ }
103
+
104
+ /** PIDs (other than ours) with any of `paths` open; null where /proc is unavailable. */
105
+ function processesHolding(paths: string[]): number[] | null {
106
+ if (!existsSync("/proc/self/fd")) return null;
107
+ const wanted = new Set(paths);
108
+ const holders: number[] = [];
109
+ for (const entry of readdirSync("/proc")) {
110
+ const pid = Number(entry);
111
+ if (!Number.isInteger(pid) || pid === process.pid) continue;
112
+ let fds: string[];
113
+ try { fds = readdirSync(`/proc/${pid}/fd`); } catch { continue; } // gone, or another user's
114
+ for (const fd of fds) {
115
+ let target = "";
116
+ try { target = readlinkSync(`/proc/${pid}/fd/${fd}`); } catch { continue; }
117
+ if (wanted.has(target.replace(/ \(deleted\)$/, ""))) { holders.push(pid); break; }
118
+ }
119
+ }
120
+ return holders;
121
+ }
122
+
123
+ function publicObjectCount(pg: PgAdapter): number {
124
+ const row = pg.get<{ n: number }>(
125
+ `SELECT (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public')
126
+ + (SELECT COUNT(*) FROM information_schema.sequences WHERE sequence_schema = 'public')
127
+ + (SELECT COUNT(*) FROM information_schema.routines WHERE routine_schema = 'public') AS n`,
128
+ );
129
+ return Number(row?.n ?? 0);
130
+ }
131
+
132
+ function dropEverythingInPublic(pg: DbAdapter) {
133
+ // Only ever called on a target that was verified empty before we started,
134
+ // so everything here was created by this run.
135
+ pg.exec(`DO $$ DECLARE r record; BEGIN
136
+ FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
137
+ EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
138
+ END LOOP;
139
+ FOR r IN SELECT p.oid::regprocedure AS sig FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public' LOOP
140
+ EXECUTE 'DROP FUNCTION IF EXISTS ' || r.sig || ' CASCADE';
141
+ END LOOP;
142
+ FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
143
+ EXECUTE 'DROP SEQUENCE IF EXISTS public.' || quote_ident(r.sequencename) || ' CASCADE';
144
+ END LOOP;
145
+ END $$`);
146
+ }
147
+
148
+ export async function migrateSqliteToPg(opts: MigrateOptions): Promise<MigrateReport> {
149
+ const log = opts.log ?? ((line: string) => console.log(line));
150
+ const home = opts.home ?? process.env.HOME ?? "";
151
+ if (!/^postgres(ql)?:\/\//.test(opts.to)) refuse("--to must be a postgres:// or postgresql:// URL");
152
+ const source = checkSource(opts.from, home, !!opts.allowCommhubCopy);
153
+ log(`[migrate-to-pg] source ${source}`);
154
+ log(`[migrate-to-pg] target ${redactPgUrl(opts.to)}${opts.dryRun ? " (dry run)" : ""}`);
155
+
156
+ // Empty-target check on a connection of our own, before the schema exists.
157
+ const probe = new PgAdapter(opts.to);
158
+ const existing = publicObjectCount(probe);
159
+ probe.close();
160
+ if (existing > 0) refuse(`target schema "public" is not empty (${existing} tables/sequences/functions). Migrate only into a new, empty database`);
161
+
162
+ // Snapshot the source: a consistent read that cannot touch the original.
163
+ const snapDir = mkdtempSync(join(tmpdir(), "anet-migrate-"));
164
+ const snapshot = join(snapDir, "snapshot.db");
165
+ try {
166
+ const original = new Database(source, { readonly: true });
167
+ original.exec(`VACUUM INTO '${snapshot.replace(/'/g, "''")}'`);
168
+ original.close();
169
+ const src = new Database(snapshot, { readonly: true, safeIntegers: true });
170
+
171
+ // Build the target schema exactly as the Hub does: import its module graph
172
+ // with DATABASE_URL pointing at the target.
173
+ delete process.env.COMMHUB_DB;
174
+ process.env.DATABASE_URL = opts.to;
175
+ await import("./server.js");
176
+ const { db } = await import("./db.js");
177
+ if (db.dialect !== "postgres") throw new Error("schema build did not select PostgreSQL");
178
+
179
+ const tables = (src.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() as { name: string }[])
180
+ .map((r) => r.name);
181
+ const report: TableReport[] = [];
182
+
183
+ try {
184
+ db.transaction(() => {
185
+ for (const table of tables) {
186
+ const srcCols = (src.query(`PRAGMA table_info(${quoteIdent(table)})`).all() as { name: string }[]).map((c) => c.name);
187
+ const tgtCols = new Set(db.all<{ column_name: string }>(
188
+ "SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?1", table,
189
+ ).map((c) => c.column_name));
190
+ const srcCount = Number((src.query(`SELECT COUNT(*) AS n FROM ${quoteIdent(table)}`).get() as { n: bigint | number }).n);
191
+ if (tgtCols.size === 0) {
192
+ if (srcCount > 0) throw new Error(`table ${table} (${srcCount} rows) does not exist in the Hub's PostgreSQL schema`);
193
+ log(`[migrate-to-pg] skip ${table}: not in the PostgreSQL schema, and empty in the source`);
194
+ continue;
195
+ }
196
+ const missing = srcCols.filter((c) => !tgtCols.has(c));
197
+ if (missing.length && srcCount > 0) throw new Error(`table ${table}: column(s) ${missing.join(", ")} do not exist in PostgreSQL`);
198
+ const cols = srcCols.filter((c) => tgtCols.has(c));
199
+ // SQLite's implicit rowid carries insertion order; PostgreSQL keeps it
200
+ // in a real `rowid` column on the tables that sort by it (RFC-039 F3).
201
+ const copyRowid = tgtCols.has("rowid") && !srcCols.includes("rowid");
202
+ const selectCols = [...(copyRowid ? ["rowid AS rowid"] : []), ...cols.map(quoteIdent)].join(", ");
203
+ const insertCols = [...(copyRowid ? ["rowid"] : []), ...cols];
204
+
205
+ // Triggers are off only while this table is being filled. Re-enabled
206
+ // in `finally` so no path — including a failed batch — leaves them
207
+ // off; a rollback also restores them (DDL is transactional in PG).
208
+ db.exec(`ALTER TABLE ${quoteIdent(table)} DISABLE TRIGGER USER`);
209
+ try {
210
+ db.run(`DELETE FROM ${quoteIdent(table)}`); // rows the schema bootstrap seeded; the source is authoritative
211
+ const perBatch = Math.max(1, Math.floor(30000 / insertCols.length));
212
+ const stmt = src.query(`SELECT ${selectCols} FROM ${quoteIdent(table)} ORDER BY rowid LIMIT ?1 OFFSET ?2`);
213
+ for (let offset = 0; offset < srcCount; offset += perBatch) {
214
+ const rows = stmt.all(perBatch, offset) as Record<string, unknown>[];
215
+ if (!rows.length) break;
216
+ const params: unknown[] = [];
217
+ const tuples = rows.map((row) => `(${insertCols.map((c) => { params.push(bindValue(row[c])); return `?${params.length}`; }).join(", ")})`);
218
+ db.run(`INSERT INTO ${quoteIdent(table)} (${insertCols.map(quoteIdent).join(", ")}) VALUES ${tuples.join(", ")}`, params);
219
+ }
220
+ } finally {
221
+ db.exec(`ALTER TABLE ${quoteIdent(table)} ENABLE TRIGGER USER`);
222
+ }
223
+
224
+ // Sequences behind BIGSERIAL columns continue after the copied ids.
225
+ for (const seq of db.all<{ column_name: string }>(
226
+ "SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?1 AND column_default LIKE 'nextval(%'", table,
227
+ )) {
228
+ db.get(`SELECT setval(pg_get_serial_sequence(?1, ?2), COALESCE((SELECT MAX(${quoteIdent(seq.column_name)}) FROM ${quoteIdent(table)}), 0) + 1, false)`,
229
+ table, seq.column_name);
230
+ }
231
+
232
+ // Verify before COMMIT.
233
+ const hashCols = copyRowid ? ["rowid", ...cols] : cols;
234
+ const srcDigest = tableDigest(src.query(`SELECT ${selectCols} FROM ${quoteIdent(table)}`).all() as Record<string, unknown>[], hashCols);
235
+ const tgtDigest = tableDigest(db.all(`SELECT ${hashCols.map(quoteIdent).join(", ")} FROM ${quoteIdent(table)}`), hashCols);
236
+ if (srcDigest.rows !== tgtDigest.rows || srcDigest.sha256 !== tgtDigest.sha256) {
237
+ throw new Error(`verification failed for ${table}: source ${srcDigest.rows} rows ${srcDigest.sha256.slice(0, 12)}, ` +
238
+ `target ${tgtDigest.rows} rows ${tgtDigest.sha256.slice(0, 12)}`);
239
+ }
240
+ report.push({ table, rows: srcDigest.rows, sha256: srcDigest.sha256 });
241
+ log(`[migrate-to-pg] ${table}: ${srcDigest.rows} rows, sha256 ${srcDigest.sha256.slice(0, 16)} ✓`);
242
+ }
243
+ if (opts.dryRun) throw new DryRunRollback("dry run");
244
+ });
245
+ } catch (e) {
246
+ const rows = report.reduce((n, t) => n + t.rows, 0);
247
+ dropEverythingInPublic(db);
248
+ src.close();
249
+ if (!(e instanceof DryRunRollback)) {
250
+ const verify = e instanceof Error && e.message.startsWith("verification failed") ? "MISMATCH" : "NOT-REACHED";
251
+ log(`[migrate-to-pg] SUMMARY result=FAILED tables_verified=${report.length}/${tables.length} rows=${rows} verify=${verify} — rolled back, target left empty`);
252
+ throw e;
253
+ }
254
+ log(`[migrate-to-pg] SUMMARY result=DRY-RUN tables=${report.length} rows=${rows} verify=OK — rolled back, target left empty`);
255
+ return { ok: true, dryRun: true, tables: report };
256
+ }
257
+ src.close();
258
+ log(`[migrate-to-pg] SUMMARY result=COMMITTED tables=${report.length} rows=${report.reduce((n, t) => n + t.rows, 0)} verify=OK`);
259
+ return { ok: true, dryRun: false, tables: report };
260
+ } finally {
261
+ rmSync(snapDir, { recursive: true, force: true });
262
+ }
263
+ }
@@ -0,0 +1,75 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { createNetworkTokenForNode, register } from "./auth.js";
6
+ import { db } from "./db.js";
7
+
8
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-network-name-http-"));
9
+ let server: any;
10
+ let base = "";
11
+ let userToken = "";
12
+ let nodeToken = "";
13
+ let networkId = "";
14
+ let username = "";
15
+
16
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
17
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
18
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
19
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
20
+ beforeAll(async () => {
21
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
22
+ username = `network_name_${Date.now()}_${process.pid}`;
23
+ const registered = register(username, "NetworkName123!", undefined, "seed");
24
+ expect(registered.ok).toBe(true);
25
+ userToken = registered.token!;
26
+ networkId = registered.network_id!;
27
+ const userId = db.get<{ user_id: string }>(
28
+ "SELECT user_id FROM users WHERE username = ?1",
29
+ [username],
30
+ )!.user_id;
31
+ const minted = createNetworkTokenForNode(userId, networkId, "network-name-node");
32
+ expect(minted.ok).toBe(true);
33
+ nodeToken = minted.token!;
34
+ const mod: any = await import("./server.js");
35
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
36
+ base = `http://127.0.0.1:${server.port}`;
37
+ }, 30_000);
38
+
39
+ afterAll(() => {
40
+ try { server?.stop?.(true); } catch {}
41
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
42
+ });
43
+
44
+ async function networks(token: string): Promise<any[]> {
45
+ const response = await fetch(`${base}/api/networks`, {
46
+ headers: { Authorization: `Bearer ${token}` },
47
+ });
48
+ expect(response.status).toBe(200);
49
+ const body = await response.json() as any;
50
+ expect(body.ok).toBe(true);
51
+ return body.networks;
52
+ }
53
+
54
+ describe("GET /api/networks name compatibility (#449)", () => {
55
+ test("utok rows expose equal non-null name and network_name", async () => {
56
+ const rows = await networks(userToken);
57
+ expect(rows).toHaveLength(1);
58
+ expect(rows[0].network_id).toBe(networkId);
59
+ // #449 is about `name` and `network_name` agreeing and being non-null.
60
+ // Pin them to the registered username rather than the literal "default":
61
+ // the auto-created network is named after its owner, so hardcoding the
62
+ // old literal would test the naming scheme instead of the compat shim.
63
+ expect(rows[0].network_name).toBe(username);
64
+ expect(rows[0].name).toBe(username);
65
+ });
66
+
67
+ test("network-bound ntok rows expose the same alias", async () => {
68
+ const rows = await networks(nodeToken);
69
+ expect(rows).toHaveLength(1);
70
+ expect(rows[0].network_id).toBe(networkId);
71
+ expect(rows[0].network_name).toBe(username);
72
+ expect(rows[0].name).toBe(rows[0].network_name);
73
+ expect(rows[0].name).not.toBeNull();
74
+ });
75
+ });
@@ -0,0 +1,224 @@
1
+ // Network-scope resolution shared by BOTH transports.
2
+ //
3
+ // #517: REST (server.ts) and MCP (tools.ts) each carried their own copy of
4
+ // the "which network does this caller write to" rule. REST grew a
5
+ // single-network fallback (POST /api/task); MCP never did — so a utok_
6
+ // caller could read everything but write nothing, with a misleading
7
+ // permission_denied. Extracted here (same pattern as lifecycle-guard.ts)
8
+ // so the rule has exactly ONE implementation. If you change write-scope
9
+ // semantics, this module is the only place — do NOT re-inline a copy in
10
+ // server.ts or tools.ts.
11
+
12
+ import { db } from "./db.js";
13
+ import { getUserNetworkRole } from "./auth.js";
14
+ import { isAgentRestricted, restrictedNetworkIds, usernameIsAgentAlias, visibleAgents } from "./agent-access.js";
15
+
16
+ export type RestNetworkScope = {
17
+ networkId: string | null;
18
+ networkIds: string[] | null;
19
+ denied?: string;
20
+ // 多用户 Agent 权限:scope 里哪些网络对这个调用者是「只看授权 Agent」的。
21
+ // addNetworkScope 对这些网络 fail-closed(整网不返回);要按授权放行的路径
22
+ // 显式改用 addAgentNetworkScope / addOwnTrafficScope,人类数据用 addHumanNetworkScope。
23
+ agentRestriction?: { userId: string; username: string; networkIds: string[] };
24
+ };
25
+
26
+ type ScopeAuthCtx = { userId: string; networkId: string | null; username?: string | null };
27
+
28
+ function usernameOf(userId: string, known?: string | null): string {
29
+ if (known) return known;
30
+ return db.get<{ username: string }>("SELECT username FROM users WHERE user_id = ?1", userId)?.username ?? "";
31
+ }
32
+
33
+ function withAgentRestriction(scope: RestNetworkScope, authCtx: ScopeAuthCtx): RestNetworkScope {
34
+ const inScope = scope.networkId ? [scope.networkId] : (scope.networkIds ?? []);
35
+ if (inScope.length === 0) return scope;
36
+ const restricted = restrictedNetworkIds(authCtx.userId).filter((id) => inScope.includes(id));
37
+ if (restricted.length === 0) return scope;
38
+ return { ...scope, agentRestriction: { userId: authCtx.userId, username: usernameOf(authCtx.userId, authCtx.username), networkIds: restricted } };
39
+ }
40
+
41
+ export function getUserNetworkIds(userId: string): string[] {
42
+ // JOIN networks: deleteNetwork historically left network_members rows
43
+ // behind (fixed alongside PR #519, but legacy DBs still carry strays).
44
+ // A ghost membership must neither make a single-network user look
45
+ // ambiguous nor let the write fallback resolve INTO a deleted network.
46
+ return db.all<{ network_id: string }>(
47
+ `SELECT m.network_id FROM network_members m
48
+ JOIN networks n ON n.network_id = m.network_id
49
+ WHERE m.user_id = ?1`,
50
+ userId
51
+ ).map((row) => row.network_id);
52
+ }
53
+
54
+ export function resolveRestNetworkScope(requested: string | null, authCtx: ScopeAuthCtx | null, isAdmin: boolean): RestNetworkScope {
55
+ // Legacy global token or open dev mode keeps the old global behavior.
56
+ if (!authCtx) return { networkId: requested || null, networkIds: null };
57
+
58
+ // Network tokens are forcibly scoped to their bound network.
59
+ if (authCtx.networkId) return { networkId: authCtx.networkId, networkIds: null };
60
+
61
+ // System admins may intentionally inspect all networks.
62
+ if (isAdmin) return { networkId: requested || null, networkIds: null };
63
+
64
+ if (requested) {
65
+ const role = getUserNetworkRole(authCtx.userId, requested);
66
+ if (!role) return { networkId: null, networkIds: [], denied: "access denied to requested network" };
67
+ return withAgentRestriction({ networkId: requested, networkIds: null }, authCtx);
68
+ }
69
+
70
+ return withAgentRestriction({ networkId: null, networkIds: getUserNetworkIds(authCtx.userId) }, authCtx);
71
+ }
72
+
73
+ function unrestrictedNetworks(scope: RestNetworkScope): string[] | null {
74
+ const excluded = scope.agentRestriction?.networkIds ?? [];
75
+ if (scope.networkId) return excluded.includes(scope.networkId) ? [] : [scope.networkId];
76
+ if (scope.networkIds) return scope.networkIds.filter((id) => !excluded.includes(id));
77
+ return null;
78
+ }
79
+
80
+ function placeholders(params: any[], values: string[]): string {
81
+ const start = params.length;
82
+ params.push(...values);
83
+ return values.map((_, i) => `?${start + i + 1}`).join(", ");
84
+ }
85
+
86
+ /**
87
+ * 默认的网络作用域 —— 对受限成员的受限网络 **fail-closed**:整网不返回。
88
+ * 没被逐条审过、不知道按授权怎么放行的查询,默认走这里,受限成员什么都看不到。
89
+ */
90
+ export function addNetworkScope(sql: string, params: any[], scope: RestNetworkScope, column = "network_id"): string {
91
+ if (!scope.agentRestriction) return addHumanNetworkScope(sql, params, scope, column);
92
+ const allowed = unrestrictedNetworks(scope) ?? [];
93
+ if (allowed.length === 0) return `${sql} AND 1=0`;
94
+ return `${sql} AND ${column} IN (${placeholders(params, allowed)})`;
95
+ }
96
+
97
+ /**
98
+ * 人类数据(自己的私信收件箱、需求看板、项目、网络成员)的作用域:受限成员照常看得到
99
+ * 自己所在网络。只给已确认不含 Agent 内容的查询用。
100
+ */
101
+ export function addHumanNetworkScope(sql: string, params: any[], scope: RestNetworkScope, column = "network_id"): string {
102
+ if (scope.networkId) {
103
+ sql += ` AND ${column} = ?${params.length + 1}`;
104
+ params.push(scope.networkId);
105
+ } else if (scope.networkIds) {
106
+ if (scope.networkIds.length === 0) {
107
+ sql += " AND 1=0";
108
+ } else {
109
+ const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
110
+ sql += ` AND ${column} IN (${placeholders})`;
111
+ params.push(...scope.networkIds);
112
+ }
113
+ }
114
+ return sql;
115
+ }
116
+
117
+ type AgentColumns = { network?: string; alias: string; nodeId?: string };
118
+
119
+ /**
120
+ * Agent 行(sessions / nodes)的作用域:不受限的网络整网放行;受限网络只放行授权的 Agent。
121
+ * mode="message" 时只放行 can_message 的授权。
122
+ */
123
+ export function addAgentNetworkScope(sql: string, params: any[], scope: RestNetworkScope, cols: AgentColumns, mode: "see" | "message" = "see"): string {
124
+ const restriction = scope.agentRestriction;
125
+ if (!restriction) return addHumanNetworkScope(sql, params, scope, cols.network ?? "network_id");
126
+ const net = cols.network ?? "network_id";
127
+ const parts: string[] = [];
128
+ const allowed = unrestrictedNetworks(scope) ?? [];
129
+ if (allowed.length) parts.push(`${net} IN (${placeholders(params, allowed)})`);
130
+ for (const networkId of restriction.networkIds) {
131
+ const visible = visibleAgents(restriction.userId, networkId);
132
+ const aliases = mode === "message" ? visible.messageAliases : visible.aliases;
133
+ const nodeIds = mode === "message" ? visible.messageNodeIds : visible.nodeIds;
134
+ const conds: string[] = [];
135
+ if (aliases.length) conds.push(`${cols.alias} IN (${placeholders(params, aliases)})`);
136
+ if (cols.nodeId && nodeIds.length) conds.push(`${cols.nodeId} IN (${placeholders(params, nodeIds)})`);
137
+ if (conds.length === 0) continue;
138
+ parts.push(`(${net} = ${placeholders(params, [networkId])} AND (${conds.join(" OR ")}))`);
139
+ }
140
+ return parts.length ? `${sql} AND (${parts.join(" OR ")})` : `${sql} AND 1=0`;
141
+ }
142
+
143
+ type TrafficColumns = { network?: string; from: string; to: string; fromNodeId?: string; toNodeId?: string };
144
+
145
+ /**
146
+ * 任务 / inbox 这类「谁发给谁」的行:受限网络里只放行**自己**与**授权 Agent**之间的往来
147
+ * (from=自己 且 to=授权 Agent,或反过来)。别人(包括 owner)和同一个 Agent 的对话看不到。
148
+ */
149
+ export function addOwnTrafficScope(sql: string, params: any[], scope: RestNetworkScope, cols: TrafficColumns): string {
150
+ const restriction = scope.agentRestriction;
151
+ if (!restriction) return addHumanNetworkScope(sql, params, scope, cols.network ?? "network_id");
152
+ const net = cols.network ?? "network_id";
153
+ const parts: string[] = [];
154
+ const allowed = unrestrictedNetworks(scope) ?? [];
155
+ if (allowed.length) parts.push(`${net} IN (${placeholders(params, allowed)})`);
156
+ if (restriction.username) {
157
+ for (const networkId of restriction.networkIds) {
158
+ // 用户名与本网络某个 Agent 的 alias 撞名时(入网时拦过,但 Agent 之后注册的 alias 拦不住),
159
+ // 「发给我的」无法和「发给那个 Agent 的」区分开 —— 这个网络的往来一律不放行。
160
+ if (usernameIsAgentAlias(networkId, restriction.username)) continue;
161
+ const visible = visibleAgents(restriction.userId, networkId);
162
+ // 先判空再生成占位符:side() 会往 params 里推值,推了又丢弃会让参数个数对不上。
163
+ const useNodeIds = !!(cols.fromNodeId && cols.toNodeId) && visible.nodeIds.length > 0;
164
+ if (visible.aliases.length === 0 && !useNodeIds) continue;
165
+ const side = (aliasCol: string, nodeCol?: string): string | null => {
166
+ const conds: string[] = [];
167
+ if (visible.aliases.length) conds.push(`${aliasCol} IN (${placeholders(params, visible.aliases)})`);
168
+ if (nodeCol && useNodeIds) conds.push(`${nodeCol} IN (${placeholders(params, visible.nodeIds)})`);
169
+ return conds.length ? `(${conds.join(" OR ")})` : null;
170
+ };
171
+ const toAgent = side(cols.to, cols.toNodeId)!;
172
+ const fromAgent = side(cols.from, cols.fromNodeId)!;
173
+ const me = placeholders(params, [restriction.username]);
174
+ parts.push(`(${net} = ${placeholders(params, [networkId])} AND ((${cols.from} = ${me} AND ${toAgent}) OR (${cols.to} = ${me} AND ${fromAgent})))`);
175
+ }
176
+ }
177
+ return parts.length ? `${sql} AND (${parts.join(" OR ")})` : `${sql} AND 1=0`;
178
+ }
179
+
180
+ export function singleNetworkId(scope: RestNetworkScope): string | null {
181
+ if (scope.networkId) return scope.networkId;
182
+ if (scope.networkIds?.length === 1) return scope.networkIds[0];
183
+ return null;
184
+ }
185
+
186
+ /**
187
+ * Resolve one unambiguous network for a REST write.
188
+ *
189
+ * Admin read scope is intentionally global (`networkIds=null`), so it cannot
190
+ * by itself express that an admin happens to have exactly one membership.
191
+ * Writes must not inherit that ambiguity: use the sole real membership when
192
+ * one exists, and keep requiring an explicit network for zero or 2+.
193
+ */
194
+ export function resolveRestWriteNetworkId(
195
+ scope: RestNetworkScope,
196
+ authCtx: { userId: string; networkId: string | null } | null,
197
+ isAdmin: boolean,
198
+ ): string | null {
199
+ const scoped = singleNetworkId(scope);
200
+ if (scoped) return scoped;
201
+ if (!authCtx || !isAdmin) return null;
202
+ const memberships = getUserNetworkIds(authCtx.userId);
203
+ return memberships.length === 1 ? memberships[0] : null;
204
+ }
205
+
206
+ /**
207
+ * 能不能往这个网络写**面向 Agent 的东西**(派任务、改节点、排程……)。
208
+ * 受限成员在受限网络里恒为 false(fail-closed);给授权 Agent 发任务的路径另用
209
+ * canMessageAgent 单独放行,人类侧的写入(私信、需求、上传)用 canRestWriteNetworkAsHuman。
210
+ */
211
+ export function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
212
+ if (!canRestWriteNetworkAsHuman(authCtx, networkId, isAdmin)) return false;
213
+ if (!authCtx || isAdmin || !networkId) return true;
214
+ return !isAgentRestricted(authCtx.userId, networkId);
215
+ }
216
+
217
+ /** 旧的「成员且不是 viewer」判据,不看 Agent 授权。只给人类侧写入用。 */
218
+ export function canRestWriteNetworkAsHuman(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
219
+ if (!authCtx) return true; // legacy global token or open dev mode
220
+ if (isAdmin) return true;
221
+ if (!networkId) return false;
222
+ const role = getUserNetworkRole(authCtx.userId, networkId);
223
+ return !!role && role !== "viewer";
224
+ }
@@ -0,0 +1,88 @@
1
+ // Boundary validation for node DISPLAY attributes (display_name / team /
2
+ // tags). Mirrors `avatar-validate.ts` in spirit: this is the only place
3
+ // that decides what a client is allowed to store, and it trusts nothing.
4
+ //
5
+ // Two different postures on purpose:
6
+ //
7
+ // display_name / team — SCALAR fields the user typed. A wrong TYPE here
8
+ // is a client bug, so it is REJECTED (400) rather than coerced: silently
9
+ // turning `{}` into "[object Object]" would persist garbage the user
10
+ // never typed and can't explain.
11
+ //
12
+ // tags — a LIST. Same reasoning as the `channels` patch in tools.ts: the
13
+ // wire contract wants junk dropped instead of failing the whole request,
14
+ // so one fat-fingered entry from a dashboard doesn't 400 the save. Each
15
+ // element is narrowed independently (typeof → trim → length cap →
16
+ // dedup), so nothing non-string can reach the database.
17
+
18
+ /** Max stored length of display_name / team. */
19
+ export const MAX_ATTR_LEN = 120;
20
+ /** Max stored length of a single tag. */
21
+ export const MAX_TAG_LEN = 64;
22
+ /** Max number of tags kept per node. */
23
+ export const MAX_TAGS = 16;
24
+
25
+ export type ScalarAttrResult =
26
+ | { ok: true; value: string | null }
27
+ | { ok: false; reason: string };
28
+
29
+ /**
30
+ * Narrow an optional scalar display attribute.
31
+ * undefined → not part of this patch (caller leaves the column alone)
32
+ * null / "" → explicit clear
33
+ * non-string → rejected (never coerced)
34
+ * over-long → rejected (the user should see it, not silently lose text)
35
+ */
36
+ export function validateScalarAttr(raw: unknown, field: string): ScalarAttrResult {
37
+ if (raw === undefined) return { ok: true, value: null };
38
+ if (raw === null) return { ok: true, value: null };
39
+ if (typeof raw !== "string") {
40
+ return { ok: false, reason: `${field} must be a string or null` };
41
+ }
42
+ const trimmed = raw.trim();
43
+ if (trimmed.length === 0) return { ok: true, value: null };
44
+ if (trimmed.length > MAX_ATTR_LEN) {
45
+ return { ok: false, reason: `${field} exceeds ${MAX_ATTR_LEN} characters` };
46
+ }
47
+ // Control characters would corrupt any log line / table cell that renders
48
+ // the value; strip rather than reject (they are almost always a paste
49
+ // artefact, not user intent).
50
+ // eslint-disable-next-line no-control-regex
51
+ const clean = trimmed.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
52
+ return { ok: true, value: clean.length > 0 ? clean : null };
53
+ }
54
+
55
+ /**
56
+ * Narrow an untrusted tags array. NEVER throws, never rejects the request:
57
+ * unusable entries are dropped. Returns the canonical list to store.
58
+ */
59
+ export function narrowTags(raw: unknown): string[] {
60
+ if (!Array.isArray(raw)) return [];
61
+ const out: string[] = [];
62
+ const seen = new Set<string>();
63
+ for (const item of raw) {
64
+ if (typeof item !== "string") continue; // numbers/objects/arrays/null
65
+ // eslint-disable-next-line no-control-regex
66
+ const t = item.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
67
+ if (t.length === 0) continue;
68
+ if (t.length > MAX_TAG_LEN) continue; // absurd entry — drop, don't truncate
69
+ const key = t.toLowerCase(); // case-fold for dedup, keep first spelling
70
+ if (seen.has(key)) continue;
71
+ seen.add(key);
72
+ out.push(t);
73
+ if (out.length >= MAX_TAGS) break;
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /** Parse the stored tags column back into an array. Storage is JSON text;
79
+ * anything unparseable (hand-edited DB, older format) reads as empty so a
80
+ * dashboard can always `.map()` without a guard. */
81
+ export function parseStoredTags(stored: unknown): string[] {
82
+ if (typeof stored !== "string" || stored.length === 0) return [];
83
+ try {
84
+ return narrowTags(JSON.parse(stored));
85
+ } catch {
86
+ return [];
87
+ }
88
+ }