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/CLAUDE.md +10 -4
- package/README.md +164 -24
- package/SECURITY.md +25 -16
- package/docs/artifact-schema-v1.md +73 -50
- package/docs/central-team-deployment-design.md +56 -52
- package/docs/integrations.md +29 -25
- package/docs/mcp-public-api.md +75 -57
- package/docs/network-sharing.md +8 -8
- package/docs/release-checklist.md +29 -13
- package/docs/roadmap-design.md +145 -133
- package/docs/sarif-csv-artifact-plan.md +25 -26
- package/docs/threat-model.md +121 -100
- package/docs/upgrade-backups.md +89 -0
- package/docs/writing-style.md +56 -0
- package/package.json +5 -3
- package/src/cli.js +78 -3
- package/src/lib/backup.js +19 -6
- package/src/lib/doctor.js +1 -1
- package/src/lib/i18n.js +142 -0
- package/src/lib/network-policy.js +234 -0
- package/src/lib/render.js +142 -28
- package/src/lib/sample-content.js +191 -0
- package/src/lib/samples.js +74 -0
- package/src/lib/share-links.js +136 -0
- package/src/lib/storage.js +458 -45
- package/src/lib/upgrade-backup.js +417 -0
- package/src/mcp-server.js +18 -6
- package/src/server.js +198 -7
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
}
|