@onpeek/nuvio 1.0.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.
Files changed (48) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +83 -0
  3. package/assets/nuvio-wordmark-dark.png +0 -0
  4. package/assets/nuvio-wordmark-light.png +0 -0
  5. package/dist/cli/confirmation.js +45 -0
  6. package/dist/cli/registry.js +14 -0
  7. package/dist/commands/account.js +66 -0
  8. package/dist/commands/addons.js +84 -0
  9. package/dist/commands/collections.js +147 -0
  10. package/dist/commands/helpers.js +186 -0
  11. package/dist/commands/index.js +26 -0
  12. package/dist/commands/library.js +105 -0
  13. package/dist/commands/plugins.js +58 -0
  14. package/dist/commands/profiles.js +126 -0
  15. package/dist/commands/providers.js +57 -0
  16. package/dist/commands/sessions.js +46 -0
  17. package/dist/commands/settings.js +43 -0
  18. package/dist/commands/trackers.js +84 -0
  19. package/dist/commands/undo.js +165 -0
  20. package/dist/config.js +60 -0
  21. package/dist/index.js +195 -0
  22. package/dist/mask.js +33 -0
  23. package/dist/nuvio/auth.js +123 -0
  24. package/dist/nuvio/call-context.js +20 -0
  25. package/dist/nuvio/client.js +149 -0
  26. package/dist/nuvio/errors.js +43 -0
  27. package/dist/nuvio/keys.js +23 -0
  28. package/dist/nuvio/ops/account.js +91 -0
  29. package/dist/nuvio/ops/addons.js +126 -0
  30. package/dist/nuvio/ops/collections.js +158 -0
  31. package/dist/nuvio/ops/library.js +139 -0
  32. package/dist/nuvio/ops/plan.js +728 -0
  33. package/dist/nuvio/ops/plugins.js +127 -0
  34. package/dist/nuvio/ops/profiles.js +383 -0
  35. package/dist/nuvio/ops/providers.js +103 -0
  36. package/dist/nuvio/ops/readers.js +57 -0
  37. package/dist/nuvio/ops/sessions.js +36 -0
  38. package/dist/nuvio/ops/settings.js +143 -0
  39. package/dist/nuvio/ops/trackers.js +80 -0
  40. package/dist/nuvio/ops/transitions.js +100 -0
  41. package/dist/nuvio/paths.js +74 -0
  42. package/dist/nuvio/safe-fetch.js +131 -0
  43. package/dist/nuvio/schemas.js +143 -0
  44. package/dist/nuvio/snapshots.js +664 -0
  45. package/dist/nuvio/types.js +1 -0
  46. package/dist/version.js +12 -0
  47. package/package.json +62 -0
  48. package/skills/nuvio/SKILL.md +19 -0
@@ -0,0 +1,36 @@
1
+ import { NuvioError } from '../errors.js';
2
+ export async function listSessions(client) {
3
+ return client.rpc('list_my_sessions', {});
4
+ }
5
+ export async function revokeSession(client, sessionId, apply) {
6
+ const sessions = await listSessions(client);
7
+ const target = sessions.find((s) => s.session_id === sessionId);
8
+ if (!target)
9
+ throw new NuvioError(`Session ${sessionId} not found`);
10
+ const diff = [`- session ${sessionId} (${target.device_name ?? target.client_name ?? 'unknown device'})`];
11
+ if (target.is_current) {
12
+ diff.push('! this is the current session; revoking it will require a new sign-in');
13
+ }
14
+ if (apply) {
15
+ await client.rpc('revoke_my_session', { p_session_id: sessionId });
16
+ }
17
+ return { applied: apply, changed: true, before: target, after: target, diff };
18
+ }
19
+ export async function registerDevice(client, device, apply) {
20
+ if (apply) {
21
+ await client.rpc('register_current_device', {
22
+ p_installation_id: device.installation_id,
23
+ p_client_name: device.client_name,
24
+ p_client_version: device.client_version ?? '',
25
+ p_device_name: device.device_name ?? '',
26
+ p_platform: device.platform ?? '',
27
+ });
28
+ }
29
+ return {
30
+ applied: apply,
31
+ changed: true,
32
+ before: {},
33
+ after: device,
34
+ diff: [`~ register device ${device.device_name ?? device.client_name}`],
35
+ };
36
+ }
@@ -0,0 +1,143 @@
1
+ import { setPath, unsetPath } from '../paths.js';
2
+ const UNSAFE_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
3
+ function assertSafeKey(key) {
4
+ if (UNSAFE_KEYS.has(key))
5
+ throw new Error(`Unsafe path segment: ${key}`);
6
+ }
7
+ function isPlainObject(value) {
8
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
9
+ }
10
+ function clone(value) {
11
+ return structuredClone(value);
12
+ }
13
+ /**
14
+ * Deep-merge a patch into a base value.
15
+ * - object + object -> recursive merge
16
+ * - array -> replace the whole array
17
+ * - scalar / type mismatch -> replace the subtree
18
+ * - null is a normal value (it replaces)
19
+ * Deletion only happens through `unset`, never by merging.
20
+ */
21
+ export function deepMerge(base, patch) {
22
+ if (isPlainObject(base) && isPlainObject(patch)) {
23
+ const out = clone(base);
24
+ for (const [key, value] of Object.entries(patch)) {
25
+ assertSafeKey(key);
26
+ out[key] = Object.prototype.hasOwnProperty.call(base, key) ? deepMerge(base[key], value) : clone(value);
27
+ }
28
+ return out;
29
+ }
30
+ return clone(patch);
31
+ }
32
+ function truncate(value, max = 160) {
33
+ if (value === undefined)
34
+ return 'undefined';
35
+ return value.length > max ? `${value.slice(0, max)}…` : value;
36
+ }
37
+ function leaf(value) {
38
+ return value === undefined ? 'undefined' : truncate(JSON.stringify(value));
39
+ }
40
+ /** Precise leaf-level diff between two settings trees (arrays are treated as leaves). */
41
+ export function diffTree(before, after, path = '', out = []) {
42
+ if (JSON.stringify(before) === JSON.stringify(after))
43
+ return out;
44
+ if (isPlainObject(before) && isPlainObject(after)) {
45
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
46
+ for (const key of [...keys].sort()) {
47
+ diffTree(before[key], after[key], path ? `${path}.${key}` : key, out);
48
+ }
49
+ return out;
50
+ }
51
+ out.push(`~ ${path || '(root)'}: ${leaf(before)} -> ${leaf(after)}`);
52
+ return out;
53
+ }
54
+ /** Apply patch -> set -> unset (in that order) to a settings tree. Pure. */
55
+ export function applySettingsEdit(base, edit) {
56
+ let after = clone(base ?? {});
57
+ if (edit.patch)
58
+ after = deepMerge(after, edit.patch);
59
+ for (const { path, value } of edit.set ?? [])
60
+ after = setPath(after, path, value);
61
+ for (const path of edit.unset ?? [])
62
+ after = unsetPath(after, path);
63
+ return { before: base ?? {}, after, diff: diffTree(base ?? {}, after) };
64
+ }
65
+ export function assertEditProvided(edit) {
66
+ if (!edit.patch && !edit.set?.length && !edit.unset?.length) {
67
+ throw new Error('Provide at least one of: patch, set, unset.');
68
+ }
69
+ }
70
+ export async function getSettings(client, profileId, platform) {
71
+ const rows = await client.readRpc('sync_pull_profile_settings_blob', {
72
+ p_profile_id: profileId,
73
+ p_platform: platform,
74
+ });
75
+ return rows[0] ?? null;
76
+ }
77
+ /** True when an error is an optimistic-concurrency rejection (guarded write). */
78
+ export function isConcurrencyConflict(error) {
79
+ const e = error;
80
+ if (e?.code === '40001' || e?.status === 409)
81
+ return true;
82
+ return typeof e?.message === 'string' && /changed on another device|concurrency|conflict/i.test(e.message);
83
+ }
84
+ export async function writeSettings(client, profileId, platform, json, updatedAt, originId) {
85
+ try {
86
+ const result = await client.rpc('sync_push_profile_settings_blob_guarded', {
87
+ p_profile_id: profileId,
88
+ p_platform: platform,
89
+ p_settings_json: json,
90
+ p_expected_updated_at: updatedAt,
91
+ });
92
+ return { revision: typeof result === 'string' && result ? result : null, guarded: true };
93
+ }
94
+ catch (error) {
95
+ if (isMissingFunction(error)) {
96
+ await client.rpc('sync_push_profile_settings_blob', {
97
+ p_profile_id: profileId,
98
+ p_platform: platform,
99
+ p_settings_json: json,
100
+ p_origin_client_id: originId,
101
+ });
102
+ return { revision: null, guarded: false };
103
+ }
104
+ throw error;
105
+ }
106
+ }
107
+ export async function updateSettings(client, profileId, platform, edit, originId, apply) {
108
+ assertEditProvided(edit);
109
+ const current = await getSettings(client, profileId, platform);
110
+ const { before, after, diff } = applySettingsEdit(current?.settings_json ?? {}, edit);
111
+ if (apply && diff.length > 0) {
112
+ await writeSettings(client, profileId, platform, after, current?.updated_at ?? null, originId);
113
+ }
114
+ return { applied: apply && diff.length > 0, changed: diff.length > 0, before, after, diff };
115
+ }
116
+ export async function getHomeCatalogSettings(client, profileId, platform) {
117
+ const rows = await client.readRpc('sync_pull_home_catalog_settings', {
118
+ p_profile_id: profileId,
119
+ p_platform: platform,
120
+ });
121
+ return rows[0] ?? null;
122
+ }
123
+ async function writeHomeCatalog(client, profileId, platform, json, originId) {
124
+ await client.rpc('sync_push_home_catalog_settings', {
125
+ p_profile_id: profileId,
126
+ p_platform: platform,
127
+ p_settings_json: json,
128
+ p_origin_client_id: originId,
129
+ });
130
+ }
131
+ export async function updateHomeCatalogSettings(client, profileId, platform, edit, originId, apply) {
132
+ assertEditProvided(edit);
133
+ const current = await getHomeCatalogSettings(client, profileId, platform);
134
+ const { before, after, diff } = applySettingsEdit(current?.settings_json ?? {}, edit);
135
+ if (apply && diff.length > 0) {
136
+ await writeHomeCatalog(client, profileId, platform, after, originId);
137
+ }
138
+ return { applied: apply && diff.length > 0, changed: diff.length > 0, before, after, diff };
139
+ }
140
+ function isMissingFunction(error) {
141
+ const e = error;
142
+ return e?.code === 'PGRST202' || e?.status === 404;
143
+ }
@@ -0,0 +1,80 @@
1
+ import { NuvioError } from '../errors.js';
2
+ export const TRACKERS = ['mal', 'anilist', 'kitsu'];
3
+ function assertTracker(tracker) {
4
+ const key = tracker.trim().toLowerCase();
5
+ if (!TRACKERS.includes(key)) {
6
+ throw new NuvioError(`Unsupported tracker "${tracker}". Supported: ${TRACKERS.join(', ')}`);
7
+ }
8
+ return key;
9
+ }
10
+ export async function listTrackerTokens(client, profileId) {
11
+ return client.readRpc('get_tracker_tokens', { p_profile_id: profileId });
12
+ }
13
+ export async function listTrackerSettings(client, profileId) {
14
+ return client.readRpc('get_profile_tracker_settings', { p_profile_id: profileId });
15
+ }
16
+ export async function setTrackerSettings(client, profileId, tracker, settings, apply) {
17
+ const key = assertTracker(tracker);
18
+ const before = await listTrackerSettings(client, profileId);
19
+ const existing = before.find((s) => s.tracker === key);
20
+ const merged = {
21
+ tracker: key,
22
+ enabled_statuses: settings.enabled_statuses ?? existing?.enabled_statuses ?? [],
23
+ row_order: settings.row_order ?? existing?.row_order ?? [],
24
+ send_progress: settings.send_progress ?? existing?.send_progress ?? true,
25
+ };
26
+ const after = existing ? before.map((s) => (s.tracker === key ? merged : s)) : [...before, merged];
27
+ const diff = [
28
+ `~ ${key} settings: enabled_statuses=${merged.enabled_statuses.length}, send_progress=${merged.send_progress}`,
29
+ ];
30
+ if (apply) {
31
+ await client.rpc('upsert_profile_tracker_settings', {
32
+ p_profile_id: profileId,
33
+ p_tracker: key,
34
+ p_enabled_statuses: merged.enabled_statuses,
35
+ p_row_order: merged.row_order,
36
+ p_send_progress: merged.send_progress,
37
+ });
38
+ }
39
+ return { applied: apply, changed: true, before, after, diff };
40
+ }
41
+ export async function setTrackerToken(client, profileId, tracker, token, apply) {
42
+ const key = assertTracker(tracker);
43
+ if (!token.access_token)
44
+ throw new NuvioError('access_token is required to link a tracker');
45
+ const before = await listTrackerTokens(client, profileId);
46
+ const after = [
47
+ ...before.filter((t) => t.tracker !== key),
48
+ {
49
+ tracker: key,
50
+ access_token: token.access_token,
51
+ refresh_token: token.refresh_token,
52
+ tracker_user_id: token.tracker_user_id,
53
+ tracker_username: token.username,
54
+ },
55
+ ];
56
+ if (apply) {
57
+ await client.rpc('upsert_tracker_tokens', {
58
+ p_profile_id: profileId,
59
+ p_tracker: key,
60
+ p_access_token: token.access_token,
61
+ p_refresh_token: token.refresh_token ?? '',
62
+ p_expires_in_seconds: token.expires_in_seconds ?? 3600,
63
+ p_tracker_user_id: token.tracker_user_id ?? '',
64
+ p_username: token.username ?? '',
65
+ });
66
+ }
67
+ return { applied: apply, changed: true, before, after, diff: [`~ link ${key} tracker`] };
68
+ }
69
+ export async function unlinkTracker(client, profileId, tracker, apply) {
70
+ const key = assertTracker(tracker);
71
+ const before = await listTrackerTokens(client, profileId);
72
+ if (!before.some((t) => t.tracker === key)) {
73
+ throw new NuvioError(`Tracker "${key}" is not linked on profile ${profileId}`);
74
+ }
75
+ const after = before.filter((t) => t.tracker !== key);
76
+ if (apply) {
77
+ await client.rpc('clear_tracker_tokens', { p_profile_id: profileId, p_tracker: key });
78
+ }
79
+ return { applied: apply, changed: true, before, after, diff: [`- unlink ${key} tracker`] };
80
+ }
@@ -0,0 +1,100 @@
1
+ import { historyKeyOf, libraryKeyOf, progressKeyOf, storedProgressKey } from '../keys.js';
2
+ /**
3
+ * Pure state transitions shared by the direct ops and the plan planners. Both
4
+ * paths MUST produce the same `after` state and diff for the same inputs.
5
+ * Timestamps are injected so several operations in one plan are deterministic.
6
+ */
7
+ function canonical(value) {
8
+ if (Array.isArray(value))
9
+ return value.map(canonical);
10
+ if (value && typeof value === 'object') {
11
+ const out = {};
12
+ for (const key of Object.keys(value).sort()) {
13
+ out[key] = canonical(value[key]);
14
+ }
15
+ return out;
16
+ }
17
+ return value;
18
+ }
19
+ function same(a, b) {
20
+ return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
21
+ }
22
+ export function planLibraryAdd(before, items, now) {
23
+ const after = structuredClone(before);
24
+ const diff = [];
25
+ for (const item of items) {
26
+ const key = libraryKeyOf(item);
27
+ const index = after.findIndex((i) => libraryKeyOf(i) === key);
28
+ const previous = index >= 0 ? after[index] : undefined;
29
+ const merged = { ...previous, ...item, added_at: item.added_at ?? previous?.added_at ?? now };
30
+ if (previous && same(previous, merged))
31
+ continue;
32
+ diff.push(previous ? `~ update library item ${key}` : `+ library ${key}`);
33
+ if (index >= 0)
34
+ after[index] = merged;
35
+ else
36
+ after.push(merged);
37
+ }
38
+ return { after, diff };
39
+ }
40
+ export function planLibraryRemove(before, keys) {
41
+ const wanted = new Set(keys.map((k) => libraryKeyOf(k)));
42
+ return {
43
+ after: before.filter((i) => !wanted.has(libraryKeyOf(i))),
44
+ diff: keys.map((k) => `- library ${libraryKeyOf(k)}`),
45
+ };
46
+ }
47
+ export function planProgressSet(before, entries, now) {
48
+ const after = structuredClone(before);
49
+ const diff = [];
50
+ for (const entry of entries) {
51
+ const key = progressKeyOf(entry);
52
+ const index = after.findIndex((p) => storedProgressKey(p) === key);
53
+ const previous = index >= 0 ? after[index] : undefined;
54
+ const merged = { ...previous, ...entry, last_watched: entry.last_watched ?? now };
55
+ const scope = entry.season != null ? ` S${entry.season}E${entry.episode}` : '';
56
+ if (previous && same(previous, merged))
57
+ continue;
58
+ diff.push(`~ progress ${entry.content_type}:${entry.content_id}${scope} @ ${entry.position}/${entry.duration}`);
59
+ if (index >= 0)
60
+ after[index] = merged;
61
+ else
62
+ after.push(merged);
63
+ }
64
+ return { after, diff };
65
+ }
66
+ export function planProgressDelete(before, keys) {
67
+ const wanted = new Set(keys.map((k) => progressKeyOf(k)));
68
+ return {
69
+ after: before.filter((p) => !wanted.has(progressKeyOf(p))),
70
+ diff: keys.map((k) => `- watch progress ${k.content_id}${k.season != null ? ` S${k.season}E${k.episode}` : ''}`),
71
+ };
72
+ }
73
+ export function planHistoryAdd(before, items, now) {
74
+ const after = structuredClone(before);
75
+ const diff = [];
76
+ for (const item of items) {
77
+ const key = historyKeyOf(item);
78
+ const index = after.findIndex((i) => historyKeyOf(i) === key);
79
+ const previous = index >= 0 ? after[index] : undefined;
80
+ const merged = { ...previous, ...item, watched_at: item.watched_at ?? previous?.watched_at ?? now };
81
+ if (previous && same(previous, merged))
82
+ continue;
83
+ const scope = item.season != null ? ` S${item.season}E${item.episode}` : '';
84
+ diff.push(previous
85
+ ? `~ watched ${item.content_type}:${item.content_id}${scope}`
86
+ : `+ watched ${item.content_type}:${item.content_id}${scope}`);
87
+ if (index >= 0)
88
+ after[index] = merged;
89
+ else
90
+ after.push(merged);
91
+ }
92
+ return { after, diff };
93
+ }
94
+ export function planHistoryDelete(before, keys) {
95
+ const wanted = new Set(keys.map((k) => historyKeyOf(k)));
96
+ return {
97
+ after: before.filter((i) => !wanted.has(historyKeyOf(i))),
98
+ diff: keys.map((k) => `- watch history ${k.content_id}${k.season != null ? ` S${k.season}E${k.episode}` : ''}`),
99
+ };
100
+ }
@@ -0,0 +1,74 @@
1
+ const UNSAFE_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
2
+ function assertSafeSegment(segment) {
3
+ if (UNSAFE_SEGMENTS.has(segment)) {
4
+ throw new Error(`Unsafe path segment: ${segment}`);
5
+ }
6
+ }
7
+ function isSafeSegment(segment) {
8
+ return !UNSAFE_SEGMENTS.has(segment);
9
+ }
10
+ export function setPath(root, path, value) {
11
+ const parts = path.split('.').filter(Boolean);
12
+ if (parts.length === 0)
13
+ throw new Error('Setting path cannot be empty');
14
+ const out = structuredClone(root);
15
+ let cursor = out;
16
+ for (let i = 0; i < parts.length - 1; i += 1) {
17
+ const key = parts[i];
18
+ assertSafeSegment(key);
19
+ const nextIsIndex = /^\d+$/.test(parts[i + 1]);
20
+ const container = cursor;
21
+ const existing = Array.isArray(cursor) ? cursor[Number(key)] : container[key];
22
+ if (existing === undefined || existing === null || typeof existing !== 'object') {
23
+ const created = nextIsIndex ? [] : {};
24
+ if (Array.isArray(cursor))
25
+ cursor[Number(key)] = created;
26
+ else
27
+ container[key] = created;
28
+ cursor = created;
29
+ }
30
+ else {
31
+ cursor = existing;
32
+ }
33
+ }
34
+ const last = parts[parts.length - 1];
35
+ assertSafeSegment(last);
36
+ if (Array.isArray(cursor) && /^\d+$/.test(last))
37
+ cursor[Number(last)] = value;
38
+ else
39
+ cursor[last] = value;
40
+ return out;
41
+ }
42
+ export function unsetPath(root, path) {
43
+ const parts = path.split('.').filter(Boolean);
44
+ if (parts.length === 0)
45
+ throw new Error('Setting path cannot be empty');
46
+ const out = structuredClone(root);
47
+ let cursor = out;
48
+ for (let i = 0; i < parts.length - 1; i += 1) {
49
+ const key = parts[i];
50
+ assertSafeSegment(key);
51
+ const existing = Array.isArray(cursor) ? cursor[Number(key)] : cursor[key];
52
+ if (existing === undefined || existing === null || typeof existing !== 'object')
53
+ return out;
54
+ cursor = existing;
55
+ }
56
+ const last = parts[parts.length - 1];
57
+ assertSafeSegment(last);
58
+ if (Array.isArray(cursor) && /^\d+$/.test(last))
59
+ cursor.splice(Number(last), 1);
60
+ else
61
+ delete cursor[last];
62
+ return out;
63
+ }
64
+ export function getPath(root, path) {
65
+ let cursor = root;
66
+ for (const key of path.split('.').filter(Boolean)) {
67
+ if (!isSafeSegment(key))
68
+ return undefined;
69
+ if (cursor === null || typeof cursor !== 'object')
70
+ return undefined;
71
+ cursor = Array.isArray(cursor) ? cursor[Number(key)] : cursor[key];
72
+ }
73
+ return cursor;
74
+ }
@@ -0,0 +1,131 @@
1
+ import { isIP } from 'node:net';
2
+ import { lookup } from 'node:dns/promises';
3
+ import { NuvioError } from './errors.js';
4
+ /**
5
+ * Fetches a JSON document from a user-supplied URL without allowing server-side
6
+ * request forgery: only http(s), no loopback/private/link-local/multicast targets,
7
+ * DNS re-resolution checked on every hop, redirects followed manually with the same
8
+ * checks, a hard timeout and a response-size cap.
9
+ */
10
+ const DEFAULT_TIMEOUT_MS = 10_000;
11
+ const DEFAULT_MAX_BYTES = 1_000_000;
12
+ const DEFAULT_MAX_REDIRECTS = 3;
13
+ function isPrivateIPv4(ip) {
14
+ const parts = ip.split('.').map((p) => Number(p));
15
+ if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255))
16
+ return true;
17
+ const [a, b] = parts;
18
+ if (a === 0 || a === 10 || a === 127)
19
+ return true;
20
+ if (a === 169 && b === 254)
21
+ return true; // link-local (cloud metadata)
22
+ if (a === 172 && b >= 16 && b <= 31)
23
+ return true;
24
+ if (a === 192 && b === 168)
25
+ return true;
26
+ if (a === 100 && b >= 64 && b <= 127)
27
+ return true; // CGNAT
28
+ if (a >= 224)
29
+ return true; // multicast / reserved
30
+ return false;
31
+ }
32
+ function isPrivateIPv6(ip) {
33
+ const lower = ip.toLowerCase();
34
+ if (lower === '::' || lower === '::1')
35
+ return true;
36
+ if (lower.startsWith('fc') || lower.startsWith('fd'))
37
+ return true; // fc00::/7 ULA
38
+ if (/^fe[89ab]/.test(lower))
39
+ return true; // fe80::/10 link-local
40
+ if (lower.startsWith('ff'))
41
+ return true; // multicast
42
+ const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
43
+ if (mapped)
44
+ return isPrivateIPv4(mapped[1]);
45
+ return false;
46
+ }
47
+ /** True when `ip` is not a globally routable unicast address. */
48
+ export function isPrivateAddress(ip) {
49
+ const version = isIP(ip);
50
+ if (version === 4)
51
+ return isPrivateIPv4(ip);
52
+ if (version === 6)
53
+ return isPrivateIPv6(ip);
54
+ return true;
55
+ }
56
+ async function assertPublicHost(hostname) {
57
+ const literal = hostname.replace(/^\[|\]$/g, '');
58
+ if (isIP(literal)) {
59
+ if (isPrivateAddress(literal)) {
60
+ throw new NuvioError(`Refusing to fetch a private/loopback address: ${hostname}`);
61
+ }
62
+ return;
63
+ }
64
+ let addresses;
65
+ try {
66
+ addresses = await lookup(hostname, { all: true });
67
+ }
68
+ catch {
69
+ throw new NuvioError(`Could not resolve host: ${hostname}`);
70
+ }
71
+ if (addresses.length === 0)
72
+ throw new NuvioError(`Could not resolve host: ${hostname}`);
73
+ for (const { address } of addresses) {
74
+ if (isPrivateAddress(address)) {
75
+ throw new NuvioError(`Refusing to fetch a host that resolves to a private address: ${hostname}`);
76
+ }
77
+ }
78
+ }
79
+ async function readCapped(res, maxBytes) {
80
+ const declared = Number(res.headers.get('content-length') ?? '');
81
+ if (Number.isFinite(declared) && declared > maxBytes) {
82
+ throw new NuvioError('Response body is too large');
83
+ }
84
+ const buffer = Buffer.from(await res.arrayBuffer());
85
+ if (buffer.byteLength > maxBytes)
86
+ throw new NuvioError('Response body is too large');
87
+ return buffer.toString('utf8');
88
+ }
89
+ export async function fetchJsonFromPublicUrl(rawUrl, options = {}) {
90
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
91
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
92
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
93
+ let current = rawUrl;
94
+ for (let hop = 0; hop <= maxRedirects; hop += 1) {
95
+ let url;
96
+ try {
97
+ url = new URL(current);
98
+ }
99
+ catch {
100
+ throw new NuvioError(`Invalid URL: ${current}`);
101
+ }
102
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
103
+ throw new NuvioError('Only http(s) URLs are supported');
104
+ }
105
+ await assertPublicHost(url.hostname);
106
+ const res = await fetch(url, {
107
+ redirect: 'manual',
108
+ headers: { accept: 'application/json' },
109
+ signal: AbortSignal.timeout(timeoutMs),
110
+ }).catch((error) => {
111
+ throw new NuvioError(`Fetch failed: ${error instanceof Error ? error.message : String(error)}`);
112
+ });
113
+ if (res.status >= 300 && res.status < 400) {
114
+ const location = res.headers.get('location');
115
+ if (!location)
116
+ throw new NuvioError(`Redirect without a Location header [http ${res.status}]`);
117
+ current = new URL(location, url).toString();
118
+ continue;
119
+ }
120
+ if (!res.ok)
121
+ throw new NuvioError(`Fetch failed [http ${res.status}]`);
122
+ const text = await readCapped(res, maxBytes);
123
+ try {
124
+ return JSON.parse(text);
125
+ }
126
+ catch {
127
+ throw new NuvioError('Response was not valid JSON');
128
+ }
129
+ }
130
+ throw new NuvioError('Too many redirects');
131
+ }