artifacty 0.11.1 → 0.12.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/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
+ }