artifacty 0.11.1 → 0.13.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.
package/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { homedir } from "node:os";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { generateToken } from "./lib/token.js";
6
7
  import { parseSseFrames } from "./lib/sse.js";
@@ -32,6 +33,27 @@ async function main() {
32
33
  return;
33
34
  }
34
35
 
36
+ // Recovery must work before loading storage or triggering a schema migration.
37
+ if (command === "backups") {
38
+ const { listUpgradeBackups, verifyUpgradeBackup, restoreUpgradeBackup } = await import("./lib/upgrade-backup.js");
39
+ const store = { home: path.resolve(options.home || process.env.ARTIFACTY_HOME || path.join(homedir(), ".artifacty")) };
40
+ const [action, id, ...extra] = options._;
41
+ if (extra.length || !["list", "verify", "restore"].includes(action) || (action === "list" ? Boolean(id) : !id)) {
42
+ throw new Error("Usage: artifacty backups list | verify <id> | restore <id> --confirm restore-store");
43
+ }
44
+ if (action === "list") {
45
+ printJson({ backups: listUpgradeBackups(store) });
46
+ } else if (action === "verify") {
47
+ printJson(verifyUpgradeBackup(store, id));
48
+ } else {
49
+ if (options.confirm !== "restore-store") {
50
+ throw new Error("Store recovery requires --confirm restore-store. Stop all server and MCP processes before restoring.");
51
+ }
52
+ printJson(restoreUpgradeBackup(store, id, { confirm: options.confirm }));
53
+ }
54
+ return;
55
+ }
56
+
35
57
  const {
36
58
  addComment,
37
59
  addRelation,
@@ -71,6 +93,16 @@ async function main() {
71
93
  const { startServer } = await import("./server.js");
72
94
  const store = createStore({ home: options.home });
73
95
 
96
+ if (command === "samples") {
97
+ const { installSamples, removeSamples } = await import("./lib/samples.js");
98
+ const action = options._[0];
99
+ if (action !== "install" && action !== "remove") {
100
+ throw new Error("Usage: artifacty samples install|remove");
101
+ }
102
+ printJson(await (action === "install" ? installSamples : removeSamples)(store, { audit: cliAuditContext() }));
103
+ return;
104
+ }
105
+
74
106
  if (command === "serve") {
75
107
  if (options.detach && options.foreground) {
76
108
  throw new Error("Use either --foreground or --detach, not both");
@@ -699,7 +731,12 @@ async function runWatchCommand(store, options) {
699
731
  for (;;) {
700
732
  const matched = await watchOnce({ baseUrl, token, filter, lastEventId, options });
701
733
  if (matched.stop) {
702
- return;
734
+ // --once has delivered its event and run any --exec command, so the
735
+ // command is complete. Flush and exit rather than waiting for every
736
+ // handle to close on its own: a socket the runtime keeps pooled would
737
+ // otherwise leave the process running with nothing left to do.
738
+ await flushStdio();
739
+ process.exit(0);
703
740
  }
704
741
  if (matched.lastEventId) {
705
742
  lastEventId = matched.lastEventId;
@@ -721,9 +758,13 @@ async function watchOnce({ baseUrl, token, filter, lastEventId, options }) {
721
758
  headers["last-event-id"] = String(lastEventId);
722
759
  }
723
760
 
761
+ // An SSE response is a long-lived socket. Cancelling the reader alone left
762
+ // it in the connection pool on some platforms, so `watch --once` could
763
+ // finish its work and still not exit; abort the request instead.
764
+ const controller = new AbortController();
724
765
  let response;
725
766
  try {
726
- response = await fetch(url, { headers });
767
+ response = await fetch(url, { headers, signal: controller.signal });
727
768
  } catch (error) {
728
769
  process.stderr.write(`artifacty watch: connection failed: ${error.message}\n`);
729
770
  return {};
@@ -766,7 +807,12 @@ async function watchOnce({ baseUrl, token, filter, lastEventId, options }) {
766
807
  const event = JSON.parse(parsed.data);
767
808
  await emitWatchEvent(event, options);
768
809
  if (options.once) {
769
- await reader.cancel().catch(() => {});
810
+ // Abort first and do not await the cancel: cancelling a live SSE
811
+ // body can sit unresolved while the server is still writing, which
812
+ // left `watch --once` hanging after it had already printed its
813
+ // event. Aborting the request tears the socket down directly.
814
+ controller.abort();
815
+ reader.cancel().catch(() => {});
770
816
  return { stop: true };
771
817
  }
772
818
  }
@@ -777,6 +823,22 @@ async function watchOnce({ baseUrl, token, filter, lastEventId, options }) {
777
823
  return { lastEventId: seenLastEventId };
778
824
  }
779
825
 
826
+ // Resolves once buffered stdout/stderr have drained, so an explicit exit
827
+ // cannot truncate output that a caller is reading from a pipe. Bounded so a
828
+ // stuck pipe delays the exit rather than preventing it.
829
+ function flushStdio(timeoutMs = 1000) {
830
+ return Promise.race([
831
+ Promise.all([
832
+ new Promise((resolve) => process.stdout.write("", resolve)),
833
+ new Promise((resolve) => process.stderr.write("", resolve))
834
+ ]),
835
+ new Promise((resolve) => {
836
+ const timer = setTimeout(resolve, timeoutMs);
837
+ timer.unref?.();
838
+ })
839
+ ]);
840
+ }
841
+
780
842
  function printWatchReady(options) {
781
843
  if (options.json) {
782
844
  process.stderr.write(`${JSON.stringify({ ready: true })}\n`);
@@ -930,6 +992,7 @@ Usage:
930
992
  artifacty install claude|codex|gemini|copilot|cursor|all [--dry-run] [--config <path>] [--server-path <path>] [--url http://127.0.0.1:8787] [--mcp-url http://127.0.0.1:8787/mcp] [--api-token token] [--transport local|bridge] [--timeout 30000]
931
993
  artifacty check [--server-path <path>] [--timeout 5000]
932
994
  artifacty update <id> (--file <path> | --content <text>) [--title <title>] [--format html|markdown|text|json|code|svg|mermaid|react|notebook] [--expected-version N] [--visibility private|team]
995
+ artifacty samples install|remove
933
996
  artifacty archive <id>
934
997
  artifacty restore <id>
935
998
  artifacty visibility <id> private|team
@@ -941,6 +1004,9 @@ Usage:
941
1004
  artifacty retention run [--dry-run] [--allow-purge]
942
1005
  artifacty export --file <path> [--full]
943
1006
  artifacty backup [--file <path>] [--full]
1007
+ artifacty backups list [--home <path>]
1008
+ artifacty backups verify <id> [--home <path>]
1009
+ artifacty backups restore <id> --confirm restore-store [--home <path>]
944
1010
  artifacty import-store --file <path> [--confirm replace-all] [--force-users]
945
1011
  artifacty users import --file <path.csv> [--no-password-reset]
946
1012
  artifacty service plist|unit|task|install|uninstall [--platform macos|linux|windows] [--dry-run] [--path <path>] [--mcp-http]
@@ -959,8 +1025,17 @@ Usage:
959
1025
  artifacty diff <id> [--from N] [--to M] [--structured] [--json]
960
1026
  artifacty watch [--tag tag] [--artifact <id>] [--type artifact.updated] [--json] [--once] [--exec "<cmd>"]
961
1027
 
1028
+ Upgrade recovery:
1029
+ Existing stores receive a mandatory snapshot before a schema migration.
1030
+ Set ARTIFACTY_BACKUP_ON_UPDATE=true to also snapshot before app-version changes.
1031
+ backups list and verify do not migrate the store.
1032
+ Stop all server and MCP processes before backups restore. Restore preserves a recovery snapshot.
1033
+ artifacty restore <id> restores an archived artifact, not a store backup.
1034
+
962
1035
  Environment:
963
1036
  ARTIFACTY_HOME Storage directory. Defaults to ~/.artifacty
1037
+ ARTIFACTY_BACKUP_ON_UPDATE Set true for optional app-version snapshots; schema backups are mandatory
1038
+ ARTIFACTY_UPGRADE_BACKUP_KEEP Automatic snapshots to retain after a successful upgrade (default: 3)
964
1039
  ARTIFACTY_URL Public URL override. Otherwise CLI/MCP read the last running server URL
965
1040
  ARTIFACTY_HOST Bind host for the HTTP server. Defaults to 127.0.0.1
966
1041
  ARTIFACTY_PORT Bind port for the HTTP server. Defaults to 8787
package/src/lib/backup.js CHANGED
@@ -172,21 +172,21 @@ export async function importStoreBundle(store = createStore(), bundle, { confirm
172
172
  // artifact's comments/relations/embeddings (foreign keys, ON DELETE
173
173
  // CASCADE) — but an artifacts-scope bundle never carries or restores
174
174
  // those tables. Silently wiping a target store's comment/relation
175
- // history because someone ran an artifacts-only restore would be a
175
+ // history or sharing grants because someone ran an artifacts-only restore would be a
176
176
  // surprising, unrecoverable data loss, so refuse unless the caller
177
177
  // explicitly opts in the same way a full-scope restore does.
178
178
  const dependents = countArtifactDependents(store);
179
- if (dependents.comments > 0 || dependents.relations > 0) {
179
+ if (dependents.comments > 0 || dependents.relations > 0 || dependents.shares > 0) {
180
180
  if (confirm !== "replace-all") {
181
181
  throw Object.assign(
182
182
  new Error(
183
- 'Target store has comments and/or relations that this artifacts-scope restore would delete without restoring equivalents. Pass confirm: "replace-all" to proceed anyway, or use a full-scope backup.'
183
+ 'Target store has comments, relations, and/or sharing grants that this artifacts-scope restore would delete without restoring equivalents. Pass confirm: "replace-all" to proceed anyway, or use a full-scope backup.'
184
184
  ),
185
185
  { statusCode: 409, code: "dependents_exist" }
186
186
  );
187
187
  }
188
188
  warnings.push(
189
- `Target store's ${dependents.comments} comment(s) and ${dependents.relations} relation(s) were deleted by this artifacts-scope restore and are not restored (only a full-scope backup carries them).`
189
+ `Target store's ${dependents.comments} comment(s) and ${dependents.relations} relation(s), and ${dependents.shares} sharing grant(s) were deleted by this artifacts-scope restore and are not restored (only a full-scope backup carries them).`
190
190
  );
191
191
  }
192
192
  }
@@ -238,7 +238,8 @@ function countArtifactDependents(store) {
238
238
  try {
239
239
  return {
240
240
  comments: db.prepare("SELECT COUNT(*) AS count FROM artifact_comments").get().count,
241
- relations: db.prepare("SELECT COUNT(*) AS count FROM artifact_relations").get().count
241
+ relations: db.prepare("SELECT COUNT(*) AS count FROM artifact_relations").get().count,
242
+ shares: db.prepare("SELECT COUNT(*) AS count FROM artifact_shares").get().count
242
243
  };
243
244
  } finally {
244
245
  db.close();
@@ -254,7 +255,11 @@ const FULL_BACKUP_TABLE_ARRAY_KEYS = [
254
255
  "comments",
255
256
  "savedViews",
256
257
  "embeddings",
257
- "meta"
258
+ "meta",
259
+ "teams",
260
+ "teamMembers",
261
+ "artifactShares",
262
+ "notifications"
258
263
  ];
259
264
 
260
265
  // Partial, fail-fast validation of a full-scope backup's `full` object,
@@ -289,6 +294,14 @@ function assertValidFullBackupTables(full) {
289
294
  });
290
295
  }
291
296
  }
297
+ for (const row of full.artifactShares || []) {
298
+ if (row?.permission !== undefined && !["read", "comment", "edit"].includes(row.permission)) {
299
+ throw Object.assign(new Error("Invalid Artifacty backup: sharing permission must be read, comment, or edit"), {
300
+ statusCode: 400,
301
+ code: "INVALID_BACKUP"
302
+ });
303
+ }
304
+ }
292
305
  for (const row of full.apiTokens || []) {
293
306
  if (!row || typeof row !== "object" || !String(row.id || "").trim() || !String(row.userId || "").trim()) {
294
307
  throw Object.assign(new Error("Invalid Artifacty backup: each `full.apiTokens` entry needs an id and userId"), {
package/src/lib/doctor.js CHANGED
@@ -9,7 +9,7 @@ import { securityConfig, validateServerExposure, exposureWarning } from "./secur
9
9
  import { serviceCommand } from "./service.js";
10
10
  import { checkStoreIntegrity, createStore } from "./storage.js";
11
11
 
12
- const MIN_NODE_VERSION = "22.5.0";
12
+ const MIN_NODE_VERSION = "22.12.0";
13
13
 
14
14
  export async function runDoctor(options = {}) {
15
15
  const packageRoot = path.resolve(options.packageRoot || process.cwd());
package/src/lib/i18n.js CHANGED
@@ -3,6 +3,77 @@ export const SUPPORTED_LOCALES = ["en", "ko"];
3
3
 
4
4
  const TRANSLATIONS = {
5
5
  en: {
6
+ "sharing.staticPreview": "Static preview; scripts and external resources are disabled.",
7
+ "sharing.safeSource": "Source preview: active content is disabled for public share links.",
8
+ "sharing.networkCondition": "All permissions also require an allowed connection IP",
9
+ "sharing.unrestricted": "Any IP",
10
+ "access.preview": "Preview mode change",
11
+ "access.previewCount": "Mode: {mode}. {count} artifacts will be readable without signing in.",
12
+ "access.pendingNetwork": "Confirm network access",
13
+ "access.pendingHelp": "This network change is temporary. Confirm by {expiresAt}, or the previous policy will be restored automatically.",
14
+ "access.confirm": "Confirm access works",
15
+ "access.rollback": "Restore previous policy",
16
+ "access.samples": "Sample manuals",
17
+ "access.samplesHelp": "Install or remove the bundled sample artifacts.",
18
+ "access.samples.install": "Install samples",
19
+ "access.samples.remove": "Remove samples",
20
+ "sharing.links": "Share links",
21
+ "sharing.enablePrivate": "Allow share links in private mode",
22
+ "sharing.effective": "Effective access",
23
+ "sharing.anonymous": "Anonymous access",
24
+ "sharing.allowed": "Allowed",
25
+ "sharing.denied": "Denied",
26
+ "sharing.permission": "Permission",
27
+ "sharing.reason": "Reason",
28
+ "sharing.permission.none": "No access",
29
+ "sharing.permission.read": "Read",
30
+ "sharing.permission.comment": "Comment",
31
+ "sharing.permission.edit": "Edit",
32
+ "sharing.permission.owner": "Owner",
33
+ "sharing.permission.admin": "Administrator",
34
+ "sharing.linksHelp": "Anyone with a valid link can read its artifact until the link expires or is revoked.",
35
+ "sharing.copyNow": "Copy this link now; its token will not be shown again.",
36
+ "sharing.version": "Version",
37
+ "sharing.latest": "Latest version",
38
+ "sharing.days": "Expires in days",
39
+ "sharing.createLink": "Create share link",
40
+ "sharing.expires": "Expires",
41
+ "sharing.revoked": "Revoked",
42
+ "sharing.revoke": "Revoke link",
43
+ "sharing.readOnly": "Shared artifact · Read only",
44
+ "inbox.title": "Inbox",
45
+ "inbox.unread": "{count} unread notifications",
46
+ "inbox.readAll": "Mark all as read",
47
+ "inbox.read": "Read",
48
+ "inbox.markRead": "Mark as read",
49
+ "inbox.empty": "No notifications.",
50
+
51
+ "access.network": "Network access",
52
+ "access.networkHelp": "Applies in both modes. Enter one IPv4 or IPv6 address or CIDR per line. Leave blank to allow all addresses. Uses the direct connection IP, not forwarded headers. The list must include your current address.",
53
+ "access.currentIp": "Your connection IP",
54
+ "access.allowedCidrs": "Allowed addresses and CIDRs",
55
+ "access.saveNetwork": "Save network access",
56
+
57
+ "access.title": "Access",
58
+ "access.mode": "Dashboard access",
59
+ "access.public": "Public",
60
+ "access.private": "Private",
61
+ "access.publicHelp": "Public mode lets anyone view team-visible artifacts without signing in. Private artifacts remain restricted.",
62
+ "access.privateHelp": "Private mode requires sign-in. Only the owner, administrators, and explicitly allowed users or team members can read an artifact.",
63
+ "access.save": "Save access settings",
64
+ "access.teams": "Teams",
65
+ "access.teamName": "Team name",
66
+ "access.createTeam": "Create team",
67
+ "access.members": "Members",
68
+ "access.saveMembers": "Save members",
69
+ "access.noUsers": "No users available.",
70
+ "access.noTeams": "No teams yet.",
71
+ "sharing.title": "Sharing",
72
+ "sharing.help": "Choose read, comment, or edit access for each user and team. The strongest user or team grant applies. The owner and administrators always retain access and control sharing. Grants restrict private mode; public team visibility may provide broader access.",
73
+ "sharing.users": "Allowed users",
74
+ "sharing.teams": "Allowed teams",
75
+ "sharing.save": "Save sharing",
76
+
6
77
  "nav.new": "New",
7
78
  "nav.import": "Import",
8
79
  "nav.api": "API",
@@ -207,6 +278,77 @@ const TRANSLATIONS = {
207
278
  "views.namePlaceholder": "View name"
208
279
  },
209
280
  ko: {
281
+ "sharing.staticPreview": "정적 미리보기: 스크립트와 외부 리소스는 비활성화됩니다.",
282
+ "sharing.safeSource": "소스 미리보기: 공개 공유 링크에서는 실행 가능한 콘텐츠를 비활성화합니다.",
283
+ "sharing.networkCondition": "모든 권한은 허용된 IP에서 접속해야 사용할 수 있습니다",
284
+ "sharing.unrestricted": "모든 IP",
285
+ "access.preview": "모드 변경 미리보기",
286
+ "access.previewCount": "모드: {mode}. 로그인 없이 볼 수 있는 아티팩트는 {count}개입니다.",
287
+ "access.pendingNetwork": "네트워크 접근 확인",
288
+ "access.pendingHelp": "임시로 적용된 네트워크 설정입니다. {expiresAt}까지 확인하지 않으면 이전 설정으로 자동 복구됩니다.",
289
+ "access.confirm": "접속 확인 및 확정",
290
+ "access.rollback": "이전 설정 복구",
291
+ "access.samples": "샘플 매뉴얼",
292
+ "access.samplesHelp": "번들로 제공되는 샘플 아티팩트를 설치하거나 제거합니다.",
293
+ "access.samples.install": "샘플 설치",
294
+ "access.samples.remove": "샘플 제거",
295
+ "sharing.links": "공유 링크",
296
+ "sharing.enablePrivate": "프라이빗 모드에서 공유 링크 허용",
297
+ "sharing.effective": "실제 접근 권한",
298
+ "sharing.anonymous": "익명 접근",
299
+ "sharing.allowed": "허용",
300
+ "sharing.denied": "차단",
301
+ "sharing.permission": "권한",
302
+ "sharing.reason": "이유",
303
+ "sharing.permission.none": "접근 불가",
304
+ "sharing.permission.read": "읽기",
305
+ "sharing.permission.comment": "댓글",
306
+ "sharing.permission.edit": "편집",
307
+ "sharing.permission.owner": "소유자",
308
+ "sharing.permission.admin": "관리자",
309
+ "sharing.linksHelp": "유효한 링크를 가진 사람은 만료되거나 폐기될 때까지 아티팩트를 읽을 수 있습니다.",
310
+ "sharing.copyNow": "지금 링크를 복사하세요. 토큰은 다시 표시되지 않습니다.",
311
+ "sharing.version": "버전",
312
+ "sharing.latest": "최신 버전",
313
+ "sharing.days": "만료까지 일수",
314
+ "sharing.createLink": "공유 링크 만들기",
315
+ "sharing.expires": "만료",
316
+ "sharing.revoked": "폐기됨",
317
+ "sharing.revoke": "링크 폐기",
318
+ "sharing.readOnly": "공유 아티팩트 · 읽기 전용",
319
+ "inbox.title": "알림함",
320
+ "inbox.unread": "읽지 않은 알림 {count}개",
321
+ "inbox.readAll": "모두 읽음 처리",
322
+ "inbox.read": "읽음",
323
+ "inbox.markRead": "읽음 처리",
324
+ "inbox.empty": "알림이 없습니다.",
325
+
326
+ "access.network": "네트워크 접근",
327
+ "access.networkHelp": "두 모드 모두에 적용됩니다. IPv4 또는 IPv6 주소나 CIDR을 한 줄에 하나씩 입력하세요. 비워두면 모든 주소를 허용합니다. 전달 헤더 대신 직접 연결된 IP를 사용합니다. 현재 접속 주소를 반드시 포함해야 합니다.",
328
+ "access.currentIp": "현재 접속 IP",
329
+ "access.allowedCidrs": "허용할 주소 및 CIDR",
330
+ "access.saveNetwork": "네트워크 접근 저장",
331
+
332
+ "access.title": "접근 관리",
333
+ "access.mode": "대시보드 접근 모드",
334
+ "access.public": "퍼블릭",
335
+ "access.private": "프라이빗",
336
+ "access.publicHelp": "퍼블릭 모드에서는 로그인 없이 팀 공개 아티팩트를 볼 수 있습니다. 비공개 아티팩트는 계속 제한됩니다.",
337
+ "access.privateHelp": "프라이빗 모드에서는 로그인이 필요합니다. 소유자, 관리자, 명시적으로 허용된 사용자 또는 팀 구성원만 아티팩트를 읽을 수 있습니다.",
338
+ "access.save": "접근 설정 저장",
339
+ "access.teams": "팀",
340
+ "access.teamName": "팀 이름",
341
+ "access.createTeam": "팀 만들기",
342
+ "access.members": "구성원",
343
+ "access.saveMembers": "구성원 저장",
344
+ "access.noUsers": "사용자가 없습니다.",
345
+ "access.noTeams": "아직 팀이 없습니다.",
346
+ "sharing.title": "공유 관리",
347
+ "sharing.help": "사용자와 팀마다 읽기, 댓글 또는 편집 권한을 선택하세요. 사용자와 소속 팀의 권한 중 가장 높은 권한이 적용됩니다. 소유자와 관리자는 항상 접근할 수 있으며 공유 설정을 관리합니다. 권한 제한은 프라이빗 모드에 적용되며 퍼블릭 팀 공개 설정은 더 넓은 접근을 허용할 수 있습니다.",
348
+ "sharing.users": "허용된 사용자",
349
+ "sharing.teams": "허용된 팀",
350
+ "sharing.save": "공유 설정 저장",
351
+
210
352
  "nav.new": "새로 만들기",
211
353
  "nav.import": "가져오기",
212
354
  "nav.api": "API",
@@ -0,0 +1,234 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { isIP } from "node:net";
3
+ import { createStore, insertAuditRow, openDatabase, transaction } from "./storage.js";
4
+
5
+ function policyError(message, code = "INVALID_NETWORK_POLICY", statusCode = 400) {
6
+ return Object.assign(new Error(message), { code, statusCode });
7
+ }
8
+
9
+ function parseAddress(address) {
10
+ if (typeof address !== "string" || address.includes("%")) return null;
11
+ const family = isIP(address);
12
+ if (!family) return null;
13
+ if (family === 4) {
14
+ return { bits: 32, value: address.split(".").reduce((value, byte) => (value << 8n) | BigInt(byte), 0n) };
15
+ }
16
+ let text = address.toLowerCase();
17
+ if (text.includes(".")) {
18
+ const separator = text.lastIndexOf(":");
19
+ const ipv4 = parseAddress(text.slice(separator + 1));
20
+ text = `${text.slice(0, separator)}:${(ipv4.value >> 16n).toString(16)}:${(ipv4.value & 65535n).toString(16)}`;
21
+ }
22
+ const halves = text.split("::");
23
+ const left = halves[0] ? halves[0].split(":") : [];
24
+ const right = halves[1] ? halves[1].split(":") : [];
25
+ const words = halves.length === 2 ? [...left, ...Array(8 - left.length - right.length).fill("0"), ...right] : left;
26
+ return { bits: 128, value: words.reduce((value, word) => (value << 16n) | BigInt(`0x${word}`), 0n) };
27
+ }
28
+
29
+ function formatAddress(value, bits) {
30
+ if (bits === 32) return [24n, 16n, 8n, 0n].map((shift) => Number((value >> shift) & 255n)).join(".");
31
+ const words = Array.from({ length: 8 }, (_, index) => ((value >> BigInt((7 - index) * 16)) & 65535n).toString(16));
32
+ let bestStart = -1;
33
+ let bestLength = 1;
34
+ for (let index = 0; index < words.length;) {
35
+ if (words[index] !== "0") { index++; continue; }
36
+ const start = index;
37
+ while (index < words.length && words[index] === "0") index++;
38
+ if (index - start > bestLength) { bestStart = start; bestLength = index - start; }
39
+ }
40
+ if (bestStart < 0) return words.join(":");
41
+ return `${words.slice(0, bestStart).join(":")}::${words.slice(bestStart + bestLength).join(":")}`;
42
+ }
43
+
44
+ function parseNetwork(entry) {
45
+ const parts = entry.split("/");
46
+ const address = parseAddress(parts[0]);
47
+ if (!address || parts.length > 2 || (parts.length === 2 && !/^\d+$/.test(parts[1]))) {
48
+ throw policyError(`Invalid IP address or CIDR: ${entry}`);
49
+ }
50
+ let { value, bits } = address;
51
+ let prefix = parts.length === 2 ? Number(parts[1]) : bits;
52
+ if (prefix > bits) throw policyError(`Invalid CIDR prefix: ${entry}`);
53
+ // Socket peers commonly use IPv4-mapped IPv6 addresses. Treat their
54
+ // IPv4-sized networks identically to ordinary IPv4 entries.
55
+ if (bits === 128 && value >> 32n === 65535n && prefix >= 96) {
56
+ bits = 32;
57
+ prefix -= 96;
58
+ value &= 0xffffffffn;
59
+ }
60
+ const shift = BigInt(bits - prefix);
61
+ value = (value >> shift) << shift;
62
+ return { value, bits, prefix, text: `${formatAddress(value, bits)}${parts.length === 2 ? `/${prefix}` : ""}` };
63
+ }
64
+
65
+ export function normalizeAllowedNetworks(input = []) {
66
+ if (typeof input !== "string" && !Array.isArray(input)) throw policyError("Allowed networks must be a list or text.");
67
+ const entries = typeof input === "string" ? input.split(/[,\r\n]+/) : input;
68
+ return [...new Set(entries.map((entry) => {
69
+ if (typeof entry !== "string") throw policyError("Each allowed network must be text.");
70
+ return entry.trim();
71
+ }).filter(Boolean).map((entry) => parseNetwork(entry).text))];
72
+ }
73
+
74
+ export function isAddressAllowed(address, allowedCidrs = []) {
75
+ const networks = normalizeAllowedNetworks(allowedCidrs);
76
+ if (!networks.length) return true;
77
+ const parsed = parseAddress(address);
78
+ if (!parsed) return false;
79
+ const candidates = [parsed];
80
+ if (parsed.bits === 128 && parsed.value >> 32n === 65535n) candidates.push({ bits: 32, value: parsed.value & 0xffffffffn });
81
+ return networks.some((entry) => {
82
+ const network = parseNetwork(entry);
83
+ const shift = BigInt(network.bits - network.prefix);
84
+ return candidates.some((candidate) => candidate.bits === network.bits && (candidate.value >> shift) === (network.value >> shift));
85
+ });
86
+ }
87
+
88
+ function readPolicy(db) {
89
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'network_policy'").get();
90
+ if (!row) return { allowedCidrs: [] };
91
+ const policy = JSON.parse(row.value);
92
+ if (!policy || !Object.hasOwn(policy, "allowedCidrs")) throw policyError("Stored network policy is invalid.");
93
+ return { allowedCidrs: normalizeAllowedNetworks(policy.allowedCidrs) };
94
+ }
95
+
96
+ function readPending(db) {
97
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'network_policy_pending'").get();
98
+ return row ? JSON.parse(row.value) : null;
99
+ }
100
+
101
+ function publicPending(pending) {
102
+ return pending ? { id: pending.id, expiresAt: pending.expiresAt, allowedCidrs: pending.allowedCidrs } : null;
103
+ }
104
+
105
+ function writePolicy(db, policy, action, audit, metadata = {}) {
106
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('network_policy', ?)").run(JSON.stringify(policy));
107
+ insertAuditRow(db, { action, artifactId: "", audit, metadata: { ...metadata, policy } });
108
+ }
109
+
110
+ function recoverExpired(db, now = Date.now()) {
111
+ const pending = readPending(db);
112
+ if (!pending || Date.parse(pending.expiresAt) > Number(now)) return false;
113
+ writePolicy(db, pending.previousPolicy, "network-policy-auto-rollback", { actor: "system:network-policy", surface: "network-policy" }, { pendingId: pending.id });
114
+ db.prepare("DELETE FROM meta WHERE key = 'network_policy_pending'").run();
115
+ return true;
116
+ }
117
+
118
+ function requireAdmin(db, access) {
119
+ const user = access?.userId && db.prepare("SELECT role, active FROM users WHERE id = ?").get(access.userId);
120
+ if (access?.role !== "admin" || !user || user.role !== "admin" || !user.active) {
121
+ throw policyError("An active administrator account is required.", "FORBIDDEN", 403);
122
+ }
123
+ }
124
+
125
+ function validatePolicy(policy, remoteAddress) {
126
+ if (!policy || !Object.hasOwn(policy, "allowedCidrs")) throw policyError("Allowed networks are required.");
127
+ const normalized = { allowedCidrs: normalizeAllowedNetworks(policy.allowedCidrs) };
128
+ if (remoteAddress !== undefined && !isAddressAllowed(remoteAddress, normalized.allowedCidrs)) {
129
+ throw policyError("Allowed networks must include your current IP address.", "NETWORK_POLICY_LOCKOUT");
130
+ }
131
+ return normalized;
132
+ }
133
+
134
+ function requireNoPending(db) {
135
+ if (readPending(db)) throw policyError("Confirm or roll back the pending network change first.", "NETWORK_POLICY_PENDING", 409);
136
+ }
137
+
138
+ export function recoverExpiredNetworkPolicy(store = createStore(), { now = Date.now() } = {}) {
139
+ const db = openDatabase(store);
140
+ try { return transaction(db, () => recoverExpired(db, now)); }
141
+ finally { db.close(); }
142
+ }
143
+
144
+ export function getNetworkPolicy(store = createStore()) {
145
+ const db = openDatabase(store);
146
+ try {
147
+ return transaction(db, () => {
148
+ recoverExpired(db);
149
+ return readPolicy(db);
150
+ });
151
+ } finally { db.close(); }
152
+ }
153
+
154
+ export function getPendingNetworkPolicy(store = createStore()) {
155
+ const db = openDatabase(store);
156
+ try {
157
+ return transaction(db, () => {
158
+ recoverExpired(db);
159
+ return publicPending(readPending(db));
160
+ });
161
+ } finally { db.close(); }
162
+ }
163
+
164
+ export function setNetworkPolicy(store = createStore(), policy = {}, { access, audit = {}, remoteAddress } = {}) {
165
+ recoverExpiredNetworkPolicy(store);
166
+ const db = openDatabase(store);
167
+ try {
168
+ return transaction(db, () => {
169
+ requireAdmin(db, access);
170
+ requireNoPending(db);
171
+ const normalized = validatePolicy(policy, remoteAddress);
172
+ writePolicy(db, normalized, "network-policy-update", { ...audit, actor: audit.actor || access.userId });
173
+ return normalized;
174
+ });
175
+ } finally { db.close(); }
176
+ }
177
+
178
+ export function stageNetworkPolicy(store = createStore(), policy = {}, { access, audit = {}, remoteAddress, timeoutMs = 120000 } = {}) {
179
+ recoverExpiredNetworkPolicy(store);
180
+ const db = openDatabase(store);
181
+ try {
182
+ return transaction(db, () => {
183
+ requireAdmin(db, access);
184
+ requireNoPending(db);
185
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 10000 || timeoutMs > 600000) {
186
+ throw policyError("Network confirmation timeout must be between 10 seconds and 10 minutes.");
187
+ }
188
+ if (!parseAddress(remoteAddress)) throw policyError("Your current IP address is required.");
189
+ const normalized = validatePolicy(policy, remoteAddress);
190
+ const pending = {
191
+ id: randomUUID(), expiresAt: new Date(Date.now() + timeoutMs).toISOString(),
192
+ allowedCidrs: normalized.allowedCidrs, previousPolicy: readPolicy(db)
193
+ };
194
+ db.prepare("INSERT INTO meta (key, value) VALUES ('network_policy_pending', ?)").run(JSON.stringify(pending));
195
+ writePolicy(db, normalized, "network-policy-stage", { ...audit, actor: audit.actor || access.userId }, { pendingId: pending.id, expiresAt: pending.expiresAt });
196
+ return publicPending(pending);
197
+ });
198
+ } finally { db.close(); }
199
+ }
200
+
201
+ function finishPending(store, id, { access, audit = {}, remoteAddress } = {}, confirm) {
202
+ // Commit expiry recovery independently so a subsequent stale-ID error cannot
203
+ // roll it back and leave a user locked out after the deadline.
204
+ recoverExpiredNetworkPolicy(store);
205
+ const db = openDatabase(store);
206
+ try {
207
+ return transaction(db, () => {
208
+ requireAdmin(db, access);
209
+ const pending = readPending(db);
210
+ if (!pending || pending.id !== id) throw policyError("Pending network change not found or expired.", "NETWORK_POLICY_NOT_PENDING", 409);
211
+ if (confirm && (!parseAddress(remoteAddress) || !isAddressAllowed(remoteAddress, pending.allowedCidrs))) {
212
+ throw policyError("Confirmation requires access from an allowed IP address.", "IP_NOT_ALLOWED", 403);
213
+ }
214
+ const policy = confirm ? { allowedCidrs: pending.allowedCidrs } : pending.previousPolicy;
215
+ writePolicy(db, policy, confirm ? "network-policy-confirm" : "network-policy-rollback", { ...audit, actor: audit.actor || access.userId }, { pendingId: pending.id });
216
+ db.prepare("DELETE FROM meta WHERE key = 'network_policy_pending'").run();
217
+ return policy;
218
+ });
219
+ } finally { db.close(); }
220
+ }
221
+
222
+ export function confirmNetworkPolicy(store = createStore(), id, options = {}) {
223
+ return finishPending(store, id, options, true);
224
+ }
225
+
226
+ export function rollbackNetworkPolicy(store = createStore(), id, options = {}) {
227
+ return finishPending(store, id, options, false);
228
+ }
229
+
230
+ export function assertNetworkAccess(store, remoteAddress) {
231
+ if (!isAddressAllowed(remoteAddress, getNetworkPolicy(store).allowedCidrs)) {
232
+ throw policyError("Access from this IP address is not allowed.", "IP_NOT_ALLOWED", 403);
233
+ }
234
+ }