@sovaria/sdk 0.0.0-stage → 0.5.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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Complete API namespace factories for SovariaSDK parity with console endpoints.
3
+ */
4
+ function safePathSegment(value) {
5
+ return encodeURIComponent(value);
6
+ }
7
+ export function createKeysApi(request) {
8
+ return {
9
+ list: () => request('GET', '/api/v1/apikeys', { requiresScope: true }),
10
+ create: (payload) => request('POST', '/api/v1/apikeys', { body: payload, requiresScope: true }),
11
+ ensureDefaults: () => request('POST', '/api/v1/apikeys/ensure-defaults', {
12
+ body: {},
13
+ requiresScope: true,
14
+ }),
15
+ revoke: (keyId) => request('DELETE', `/api/v1/apikeys/${safePathSegment(keyId)}`, {
16
+ requiresScope: true,
17
+ }),
18
+ };
19
+ }
20
+ export function createGovernanceApi(request) {
21
+ const gov = (suffix) => `/api/v1/governance/${suffix.replace(/^\//, '')}`;
22
+ return {
23
+ listRegions: () => request('GET', gov('regions')),
24
+ listAvailableRegions: (query) => request('GET', gov('regions/available'), { query }),
25
+ patchRegion: (regionId, body) => request('PATCH', gov(`regions/${safePathSegment(regionId)}`), { body }),
26
+ listTenants: () => request('GET', gov('tenants')),
27
+ getTenant: (orgId) => request('GET', gov(`tenants/${safePathSegment(orgId)}`)),
28
+ setTenantStatus: (orgId, body) => request('PATCH', gov(`tenants/${safePathSegment(orgId)}/status`), { body }),
29
+ listTenantDatabases: (orgId) => request('GET', gov(`tenants/${safePathSegment(orgId)}/databases`)),
30
+ provisionDatabase: (orgId, body) => request('POST', gov(`tenants/${safePathSegment(orgId)}/databases/provision`), {
31
+ body,
32
+ }),
33
+ getDatabaseConnection: (orgId, projectId) => request('GET', gov(`tenants/${safePathSegment(orgId)}/databases/${safePathSegment(projectId)}/connection`)),
34
+ /** Staff only: the connection string, with the active access grant that allows revealing it. */
35
+ revealDatabaseConnection: (orgId, projectId, grantId) => request('POST', gov(`tenants/${safePathSegment(orgId)}/databases/${safePathSegment(projectId)}/connection/reveal`), { body: { grant_id: grantId } }),
36
+ pauseDatabase: (orgId, projectId) => request('POST', gov(`tenants/${safePathSegment(orgId)}/databases/${safePathSegment(projectId)}/pause`), { body: {} }),
37
+ resumeDatabase: (orgId, projectId) => request('POST', gov(`tenants/${safePathSegment(orgId)}/databases/${safePathSegment(projectId)}/resume`), { body: {} }),
38
+ resizeDatabase: (orgId, projectId, body) => request('POST', gov(`tenants/${safePathSegment(orgId)}/databases/${safePathSegment(projectId)}/resize`), { body }),
39
+ deleteDatabase: (orgId, projectId, snapshot = true) => request('DELETE', gov(`tenants/${safePathSegment(orgId)}/databases/${safePathSegment(projectId)}`), { query: { snapshot } }),
40
+ listDatabaseBackups: (orgId, projectId, limit = 5) => request('GET', gov(`tenants/${safePathSegment(orgId)}/databases/${safePathSegment(projectId)}/backups`), { query: { limit } }),
41
+ triggerDatabaseBackup: (orgId, projectId) => request('POST', gov(`tenants/${safePathSegment(orgId)}/databases/${safePathSegment(projectId)}/backups`), { body: {} }),
42
+ /**
43
+ * Download a completed backup (plain SQL, gzip) as a Blob — the whole database, so owners, admins and
44
+ * developers only (`database.backup.read`). Restore it anywhere with `gunzip -c file | psql`.
45
+ */
46
+ downloadDatabaseBackup: (orgId, projectId, backupId) => request('GET', gov(`tenants/${safePathSegment(orgId)}/databases/${safePathSegment(projectId)}/backups/${safePathSegment(String(backupId))}/download`), { responseType: 'blob' }),
47
+ /** Download a portable export bundle (tar.gz: Postgres dump + migration metadata) as a Blob. */
48
+ downloadProjectExport: (orgId, projectId, exportId) => request('GET', gov(`tenants/${safePathSegment(orgId)}/databases/${safePathSegment(projectId)}/exports/${safePathSegment(exportId)}/download`), { responseType: 'blob' }),
49
+ adminListDatabases: () => request('GET', gov('admin/databases')),
50
+ listHosts: (regionId) => request('GET', gov('hosts'), { query: regionId ? { region_id: regionId } : undefined }),
51
+ patchHost: (hostId, body) => request('PATCH', gov(`hosts/${safePathSegment(hostId)}`), { body }),
52
+ metricsOverview: () => request('GET', gov('metrics/overview')),
53
+ projectMetrics: (projectId, since = '24', limit = 500) => request('GET', gov(`metrics/projects/${safePathSegment(projectId)}`), {
54
+ query: { since, limit },
55
+ }),
56
+ };
57
+ }
58
+ export function createAdminApi(request) {
59
+ return {
60
+ listErrorTraces: (query) => request('GET', '/api/v1/error-traces', { query }),
61
+ getErrorTrace: (traceId) => request('GET', `/api/v1/error-traces/${safePathSegment(traceId)}`),
62
+ resolveErrorTrace: (traceId, body = {}) => request('PATCH', `/api/v1/error-traces/${safePathSegment(traceId)}/resolved`, {
63
+ body,
64
+ }),
65
+ usageOverview: (period, limit = 100) => request('GET', '/api/v1/admin/usage/overview', {
66
+ query: { period, limit },
67
+ }),
68
+ listAccessTransparency: (query) => request('GET', '/api/v1/access-transparency', { query }),
69
+ gatewayHealth: () => request('GET', '/healthz', { requiresAuth: false }),
70
+ };
71
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * App crash reporting (plan C5): uncaught errors from your app, grouped into issues in the console
3
+ * (Observability › Crashes).
4
+ *
5
+ * ```ts
6
+ * sov.crashes.install({ release: '1.4.2' }) // browser + React Native: uncaught errors and rejections
7
+ * try { await checkout() } catch (e) { sov.crashes.capture(e, { tags: { step: 'pay' } }) }
8
+ * ```
9
+ *
10
+ * Each report carries the error type, message and stack, the release, the platform and device, the
11
+ * last 50 breadcrumbs (Sovaria requests the SDK made, plus your own `addBreadcrumb`) and the signed-in
12
+ * user (the server takes the user from the request's token, not from the report). Reports are batched,
13
+ * duplicates within a second are dropped and at most 30 a minute are sent, so a crash loop can't flood
14
+ * your plan's `obs.crash_events_month`.
15
+ *
16
+ * Release health: `install()` also starts a session (one per page load / app launch) and marks it crashed on
17
+ * an uncaught error or errored on the first handled one, so the console shows crash-free sessions and users per
18
+ * release (`trackSessions: false` turns it off). Minified stacks are turned back into your source with the
19
+ * source maps you upload for the release: `sov crashes sourcemaps upload ./dist --release 1.4.2`.
20
+ */
21
+ export interface CrashEvent {
22
+ type: string;
23
+ message: string;
24
+ stack: string[];
25
+ platform: string;
26
+ release?: string;
27
+ environment?: string;
28
+ handled: boolean;
29
+ url?: string;
30
+ device?: Record<string, unknown>;
31
+ breadcrumbs: Breadcrumb[];
32
+ tags?: Record<string, string>;
33
+ extra?: Record<string, unknown>;
34
+ fingerprint?: string;
35
+ timestamp: string;
36
+ session_id?: string;
37
+ trace_id?: string;
38
+ }
39
+ export interface SessionPing {
40
+ id: string;
41
+ status: 'init' | 'crashed' | 'errored' | 'abnormal' | 'exited';
42
+ started: string;
43
+ release?: string;
44
+ environment?: string;
45
+ platform: string;
46
+ distinct_id: string;
47
+ }
48
+ export interface Breadcrumb {
49
+ category: string;
50
+ message: string;
51
+ data?: Record<string, unknown>;
52
+ timestamp: string;
53
+ }
54
+ export interface CrashOptions {
55
+ release?: string;
56
+ environment?: string;
57
+ /** 0..1: the share of crashes to send (default 1). */
58
+ sampleRate?: number;
59
+ /** Change or drop (return null) a report before it is sent. */
60
+ beforeSend?: (event: CrashEvent) => CrashEvent | null;
61
+ /** Session pings for release health (crash-free sessions/users per release). Default true. */
62
+ trackSessions?: boolean;
63
+ }
64
+ type Sender = (events: CrashEvent[]) => Promise<unknown>;
65
+ type SessionSender = (sessions: SessionPing[]) => Promise<unknown>;
66
+ export declare class CrashReporter {
67
+ private readonly send;
68
+ private readonly sendSessions?;
69
+ private options;
70
+ private queue;
71
+ private crumbs;
72
+ private recent;
73
+ private sentThisMinute;
74
+ private minute;
75
+ private timer?;
76
+ private uninstallers;
77
+ private session?;
78
+ private marked;
79
+ installed: boolean;
80
+ constructor(send: Sender, sendSessions?: SessionSender | undefined);
81
+ /** The current session id (also sent with crash reports and performance measurements). */
82
+ get sessionId(): string | undefined;
83
+ /** Start a new release-health session (install() starts one; call this when your app returns to the foreground
84
+ * after a long time, if you count that as a new session). */
85
+ startSession(): void;
86
+ private markSession;
87
+ private ping;
88
+ /** Report uncaught errors and unhandled promise rejections (browser and React Native). Call once. */
89
+ install(options?: CrashOptions): () => void;
90
+ uninstall(): void;
91
+ addBreadcrumb(crumb: {
92
+ category?: string;
93
+ message: string;
94
+ data?: Record<string, unknown>;
95
+ }): void;
96
+ /** Report an error you caught (handled) or one the app didn't. Returns false when it was dropped. */
97
+ capture(error: unknown, context?: {
98
+ handled?: boolean;
99
+ tags?: Record<string, string>;
100
+ extra?: Record<string, unknown>;
101
+ fingerprint?: string;
102
+ }): boolean;
103
+ private schedule;
104
+ /** Send queued reports now. */
105
+ flush(): Promise<void>;
106
+ }
107
+ export declare function parseStack(error: Error): string[];
108
+ declare function detectPlatform(): string;
109
+ /** A stable anonymous id for this browser / app install (localStorage), used to count users per release. */
110
+ export declare function distinctId(): string;
111
+ export { detectPlatform };
@@ -0,0 +1,244 @@
1
+ /**
2
+ * App crash reporting (plan C5): uncaught errors from your app, grouped into issues in the console
3
+ * (Observability › Crashes).
4
+ *
5
+ * ```ts
6
+ * sov.crashes.install({ release: '1.4.2' }) // browser + React Native: uncaught errors and rejections
7
+ * try { await checkout() } catch (e) { sov.crashes.capture(e, { tags: { step: 'pay' } }) }
8
+ * ```
9
+ *
10
+ * Each report carries the error type, message and stack, the release, the platform and device, the
11
+ * last 50 breadcrumbs (Sovaria requests the SDK made, plus your own `addBreadcrumb`) and the signed-in
12
+ * user (the server takes the user from the request's token, not from the report). Reports are batched,
13
+ * duplicates within a second are dropped and at most 30 a minute are sent, so a crash loop can't flood
14
+ * your plan's `obs.crash_events_month`.
15
+ *
16
+ * Release health: `install()` also starts a session (one per page load / app launch) and marks it crashed on
17
+ * an uncaught error or errored on the first handled one, so the console shows crash-free sessions and users per
18
+ * release (`trackSessions: false` turns it off). Minified stacks are turned back into your source with the
19
+ * source maps you upload for the release: `sov crashes sourcemaps upload ./dist --release 1.4.2`.
20
+ */
21
+ const MAX_BREADCRUMBS = 50;
22
+ const MAX_PER_MINUTE = 30;
23
+ export class CrashReporter {
24
+ send;
25
+ sendSessions;
26
+ options = {};
27
+ queue = [];
28
+ crumbs = [];
29
+ recent = new Map();
30
+ sentThisMinute = 0;
31
+ minute = 0;
32
+ timer;
33
+ uninstallers = [];
34
+ session;
35
+ marked = new Set();
36
+ installed = false;
37
+ constructor(send, sendSessions) {
38
+ this.send = send;
39
+ this.sendSessions = sendSessions;
40
+ }
41
+ /** The current session id (also sent with crash reports and performance measurements). */
42
+ get sessionId() {
43
+ return this.session?.id;
44
+ }
45
+ /** Start a new release-health session (install() starts one; call this when your app returns to the foreground
46
+ * after a long time, if you count that as a new session). */
47
+ startSession() {
48
+ this.session = {
49
+ id: randomId(), status: 'init', started: new Date().toISOString(), release: this.options.release,
50
+ environment: this.options.environment, platform: detectPlatform(), distinct_id: distinctId(),
51
+ };
52
+ this.marked.clear();
53
+ void this.ping({ ...this.session });
54
+ }
55
+ markSession(status) {
56
+ if (!this.session || this.marked.has('crashed') || this.marked.has(status))
57
+ return;
58
+ this.marked.add(status);
59
+ void this.ping({ ...this.session, status });
60
+ }
61
+ async ping(s) {
62
+ if (!this.sendSessions || this.options.trackSessions === false)
63
+ return;
64
+ try {
65
+ await this.sendSessions([s]);
66
+ }
67
+ catch {
68
+ /* release health is best effort */
69
+ }
70
+ }
71
+ /** Report uncaught errors and unhandled promise rejections (browser and React Native). Call once. */
72
+ install(options = {}) {
73
+ this.options = options;
74
+ if (this.installed)
75
+ return () => this.uninstall();
76
+ this.installed = true;
77
+ if (options.trackSessions !== false)
78
+ this.startSession();
79
+ const g = globalThis;
80
+ if (typeof g.addEventListener === 'function') {
81
+ const onError = (e) => this.capture(e.error ?? new Error(String(e.message ?? 'Script error')), { handled: false });
82
+ const onRejection = (e) => this.capture(e.reason ?? new Error('Unhandled promise rejection'), { handled: false });
83
+ g.addEventListener('error', onError);
84
+ g.addEventListener('unhandledrejection', onRejection);
85
+ const onHide = () => void this.flush();
86
+ g.addEventListener('pagehide', onHide);
87
+ this.uninstallers.push(() => {
88
+ g.removeEventListener?.('error', onError);
89
+ g.removeEventListener?.('unhandledrejection', onRejection);
90
+ g.removeEventListener?.('pagehide', onHide);
91
+ });
92
+ }
93
+ // React Native: wrap the global handler, then call the previous one (keeps the red box / crash).
94
+ const errorUtils = g.ErrorUtils;
95
+ if (errorUtils?.setGlobalHandler && errorUtils.getGlobalHandler) {
96
+ const previous = errorUtils.getGlobalHandler();
97
+ errorUtils.setGlobalHandler((error, fatal) => {
98
+ this.capture(error, { handled: false, tags: { fatal: String(Boolean(fatal)) } });
99
+ void this.flush();
100
+ previous?.(error, fatal);
101
+ });
102
+ this.uninstallers.push(() => errorUtils.setGlobalHandler?.(previous));
103
+ }
104
+ return () => this.uninstall();
105
+ }
106
+ uninstall() {
107
+ for (const off of this.uninstallers.splice(0))
108
+ off();
109
+ this.installed = false;
110
+ }
111
+ addBreadcrumb(crumb) {
112
+ this.crumbs.push({ category: crumb.category ?? 'app', message: String(crumb.message).slice(0, 300), data: crumb.data,
113
+ timestamp: new Date().toISOString() });
114
+ if (this.crumbs.length > MAX_BREADCRUMBS)
115
+ this.crumbs.splice(0, this.crumbs.length - MAX_BREADCRUMBS);
116
+ }
117
+ /** Report an error you caught (handled) or one the app didn't. Returns false when it was dropped. */
118
+ capture(error, context = {}) {
119
+ const err = error instanceof Error ? error : new Error(typeof error === 'string' ? error : JSON.stringify(error ?? 'Unknown error'));
120
+ if ((this.options.sampleRate ?? 1) < 1 && Math.random() >= (this.options.sampleRate ?? 1))
121
+ return false;
122
+ const stack = parseStack(err);
123
+ const key = `${err.name}|${err.message}|${stack[0] ?? ''}`;
124
+ const now = Date.now();
125
+ if ((this.recent.get(key) ?? 0) > now - 1000)
126
+ return false;
127
+ this.recent.set(key, now);
128
+ if (this.recent.size > 200)
129
+ this.recent.clear();
130
+ const minute = Math.floor(now / 60_000);
131
+ if (minute !== this.minute) {
132
+ this.minute = minute;
133
+ this.sentThisMinute = 0;
134
+ }
135
+ if (this.sentThisMinute >= MAX_PER_MINUTE)
136
+ return false;
137
+ let event = {
138
+ type: err.name || 'Error',
139
+ message: err.message,
140
+ stack,
141
+ platform: detectPlatform(),
142
+ release: this.options.release,
143
+ environment: this.options.environment,
144
+ handled: context.handled ?? true,
145
+ url: currentUrl(),
146
+ device: deviceInfo(),
147
+ breadcrumbs: [...this.crumbs],
148
+ tags: context.tags,
149
+ extra: context.extra,
150
+ fingerprint: context.fingerprint,
151
+ timestamp: new Date().toISOString(),
152
+ session_id: this.session?.id,
153
+ };
154
+ if (this.options.beforeSend)
155
+ event = this.options.beforeSend(event);
156
+ if (!event)
157
+ return false;
158
+ this.markSession(event.handled ? 'errored' : 'crashed');
159
+ this.sentThisMinute++;
160
+ this.queue.push(event);
161
+ this.schedule();
162
+ return true;
163
+ }
164
+ schedule() {
165
+ if (this.timer)
166
+ return;
167
+ this.timer = setTimeout(() => void this.flush(), 1000);
168
+ }
169
+ /** Send queued reports now. */
170
+ async flush() {
171
+ if (this.timer) {
172
+ clearTimeout(this.timer);
173
+ this.timer = undefined;
174
+ }
175
+ while (this.queue.length) {
176
+ const batch = this.queue.splice(0, 20);
177
+ try {
178
+ await this.send(batch);
179
+ }
180
+ catch {
181
+ return; // never let crash reporting itself crash the app; the batch is dropped
182
+ }
183
+ }
184
+ }
185
+ }
186
+ export function parseStack(error) {
187
+ return String(error.stack ?? '')
188
+ .split('\n')
189
+ .map((l) => l.trim())
190
+ .filter((l) => l && !l.startsWith(`${error.name}:`) && l !== error.name)
191
+ .slice(0, 50);
192
+ }
193
+ function detectPlatform() {
194
+ const g = globalThis;
195
+ if (g.navigator?.product === 'ReactNative')
196
+ return 'react-native';
197
+ if (typeof g.window !== 'undefined' && typeof g.document !== 'undefined')
198
+ return 'web';
199
+ if (g.process?.versions?.node)
200
+ return 'node';
201
+ return 'javascript';
202
+ }
203
+ function currentUrl() {
204
+ const g = globalThis;
205
+ return typeof g.location?.href === 'string' ? g.location.href : undefined;
206
+ }
207
+ function deviceInfo() {
208
+ const g = globalThis;
209
+ const out = {};
210
+ if (g.navigator?.userAgent)
211
+ out.user_agent = String(g.navigator.userAgent).slice(0, 300);
212
+ if (g.navigator?.language)
213
+ out.language = g.navigator.language;
214
+ if (g.screen?.width)
215
+ out.screen = `${g.screen.width}x${g.screen.height}`;
216
+ if (g.process?.versions?.node)
217
+ out.node = g.process.versions.node;
218
+ return out;
219
+ }
220
+ function randomId() {
221
+ const c = globalThis.crypto;
222
+ if (c?.randomUUID)
223
+ return c.randomUUID();
224
+ return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
225
+ }
226
+ let cachedDistinct;
227
+ /** A stable anonymous id for this browser / app install (localStorage), used to count users per release. */
228
+ export function distinctId() {
229
+ if (cachedDistinct)
230
+ return cachedDistinct;
231
+ const g = globalThis;
232
+ try {
233
+ const existing = g.localStorage?.getItem('sovaria.distinct_id');
234
+ if (existing)
235
+ return (cachedDistinct = String(existing));
236
+ const id = randomId();
237
+ g.localStorage?.setItem('sovaria.distinct_id', id);
238
+ return (cachedDistinct = id);
239
+ }
240
+ catch {
241
+ return (cachedDistinct = randomId());
242
+ }
243
+ }
244
+ export { detectPlatform };
@@ -0,0 +1,37 @@
1
+ /** Argon2id parameters (OWASP recommended minimums, matching Python SDK). */
2
+ export declare const ARGON2_TIME_COST = 3;
3
+ export declare const ARGON2_MEMORY_COST = 65536;
4
+ export declare const ARGON2_PARALLELISM = 1;
5
+ export declare const ARGON2_HASH_LEN = 32;
6
+ export declare const ARGON2_SALT_LEN = 16;
7
+ export declare const DEK_SIZE_BYTES = 32;
8
+ export declare const NONCE_SIZE_BYTES = 12;
9
+ export interface EncryptedEnvelope {
10
+ ciphertext: string;
11
+ nonce: string;
12
+ auth_tag: string;
13
+ encrypted_dek: string;
14
+ dek_nonce: string;
15
+ payload_hash: string;
16
+ salt?: string;
17
+ }
18
+ /** Derive a 256-bit KEK from password + salt using Argon2id. */
19
+ export declare function deriveKek(password: string, salt: Uint8Array): Promise<Uint8Array>;
20
+ /** Generate a 128-bit cryptographically random salt. */
21
+ export declare function generateSalt(): Uint8Array;
22
+ /** Parse a hex-encoded salt string into bytes. */
23
+ export declare function hexToBytes(hex: string): Uint8Array;
24
+ /** Encode bytes as a lowercase hex string. */
25
+ export declare function bytesToHex(bytes: Uint8Array): string;
26
+ /** Best-effort RAM wipe for sensitive byte buffers. */
27
+ export declare function zeroBytes(data: Uint8Array | ArrayBuffer): void;
28
+ /** SHA-256(ciphertext_b64 || nonce_b64 || auth_tag_b64) for Gateway L11 verification. */
29
+ export declare function computePayloadHash(envelope: Pick<EncryptedEnvelope, 'ciphertext' | 'nonce' | 'auth_tag'>): Promise<string>;
30
+ /** Full Zero-Knowledge envelope encryption (derives KEK from password). */
31
+ export declare function encryptDocument(plaintext: Uint8Array, password: string, salt: Uint8Array): Promise<EncryptedEnvelope>;
32
+ /** Encrypt using a pre-derived KEK (skips Argon2id). */
33
+ export declare function encryptDocumentWithKek(plaintext: Uint8Array, kek: Uint8Array): Promise<EncryptedEnvelope>;
34
+ /** Reverse envelope encryption using password-derived KEK. */
35
+ export declare function decryptDocument(envelope: EncryptedEnvelope, password: string, salt: Uint8Array): Promise<Uint8Array>;
36
+ /** Decrypt using a pre-derived KEK (skips Argon2id). */
37
+ export declare function decryptDocumentWithKek(envelope: EncryptedEnvelope, kek: Uint8Array): Promise<Uint8Array>;
package/dist/crypto.js ADDED
@@ -0,0 +1,189 @@
1
+ import { argon2id } from 'hash-wasm';
2
+ /** Argon2id parameters (OWASP recommended minimums, matching Python SDK). */
3
+ export const ARGON2_TIME_COST = 3;
4
+ export const ARGON2_MEMORY_COST = 65536; // 64 MB in KiB
5
+ export const ARGON2_PARALLELISM = 1;
6
+ export const ARGON2_HASH_LEN = 32;
7
+ export const ARGON2_SALT_LEN = 16;
8
+ export const DEK_SIZE_BYTES = 32;
9
+ export const NONCE_SIZE_BYTES = 12;
10
+ function getSubtleCrypto() {
11
+ const subtle = globalThis.crypto?.subtle;
12
+ if (!subtle) {
13
+ throw new Error('Web Crypto API (crypto.subtle) is not available in this environment');
14
+ }
15
+ return subtle;
16
+ }
17
+ function randomBytes(length) {
18
+ const bytes = new Uint8Array(length);
19
+ globalThis.crypto.getRandomValues(bytes);
20
+ return bytes;
21
+ }
22
+ function bytesToBase64(bytes) {
23
+ let binary = '';
24
+ const chunk = 0x8000;
25
+ for (let i = 0; i < bytes.length; i += chunk) {
26
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
27
+ }
28
+ return btoa(binary);
29
+ }
30
+ function base64ToBytes(value) {
31
+ const binary = atob(value);
32
+ const bytes = new Uint8Array(binary.length);
33
+ for (let i = 0; i < binary.length; i += 1) {
34
+ bytes[i] = binary.charCodeAt(i);
35
+ }
36
+ return bytes;
37
+ }
38
+ function concatBytes(...parts) {
39
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
40
+ const out = new Uint8Array(total);
41
+ let offset = 0;
42
+ for (const part of parts) {
43
+ out.set(part, offset);
44
+ offset += part.length;
45
+ }
46
+ return out;
47
+ }
48
+ function toBufferSource(data) {
49
+ return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
50
+ }
51
+ async function sha256Hex(data) {
52
+ const digest = await getSubtleCrypto().digest('SHA-256', toBufferSource(data));
53
+ return Array.from(new Uint8Array(digest))
54
+ .map((byte) => byte.toString(16).padStart(2, '0'))
55
+ .join('');
56
+ }
57
+ async function importAesKey(keyBytes, usages) {
58
+ return getSubtleCrypto().importKey('raw', toBufferSource(keyBytes), { name: 'AES-GCM' }, false, usages);
59
+ }
60
+ async function aesGcmEncrypt(keyBytes, nonce, plaintext) {
61
+ const key = await importAesKey(keyBytes, ['encrypt']);
62
+ const full = new Uint8Array(await getSubtleCrypto().encrypt({ name: 'AES-GCM', iv: toBufferSource(nonce), tagLength: 128 }, key, toBufferSource(plaintext)));
63
+ return {
64
+ ciphertext: full.slice(0, full.length - 16),
65
+ authTag: full.slice(full.length - 16),
66
+ };
67
+ }
68
+ async function aesGcmDecrypt(keyBytes, nonce, ciphertext, authTag) {
69
+ const key = await importAesKey(keyBytes, ['decrypt']);
70
+ const full = concatBytes(ciphertext, authTag);
71
+ return new Uint8Array(await getSubtleCrypto().decrypt({ name: 'AES-GCM', iv: toBufferSource(nonce), tagLength: 128 }, key, toBufferSource(full)));
72
+ }
73
+ async function aesGcmDecryptCombined(keyBytes, nonce, ciphertextWithTag) {
74
+ const key = await importAesKey(keyBytes, ['decrypt']);
75
+ return new Uint8Array(await getSubtleCrypto().decrypt({ name: 'AES-GCM', iv: toBufferSource(nonce), tagLength: 128 }, key, toBufferSource(ciphertextWithTag)));
76
+ }
77
+ async function buildEnvelopeFromParts(docCiphertext, docNonce, authTag, encryptedDek, dekNonce) {
78
+ const ciphertext = bytesToBase64(docCiphertext);
79
+ const nonce = bytesToBase64(docNonce);
80
+ const auth_tag = bytesToBase64(authTag);
81
+ const payload_hash = await computePayloadHash({ ciphertext, nonce, auth_tag });
82
+ return {
83
+ ciphertext,
84
+ nonce,
85
+ auth_tag,
86
+ encrypted_dek: bytesToBase64(encryptedDek),
87
+ dek_nonce: bytesToBase64(dekNonce),
88
+ payload_hash,
89
+ };
90
+ }
91
+ /** Derive a 256-bit KEK from password + salt using Argon2id. */
92
+ export async function deriveKek(password, salt) {
93
+ const hash = await argon2id({
94
+ password,
95
+ salt,
96
+ iterations: ARGON2_TIME_COST,
97
+ memorySize: ARGON2_MEMORY_COST,
98
+ parallelism: ARGON2_PARALLELISM,
99
+ hashLength: ARGON2_HASH_LEN,
100
+ outputType: 'binary',
101
+ });
102
+ return hash instanceof Uint8Array ? hash : new Uint8Array(hash);
103
+ }
104
+ /** Generate a 128-bit cryptographically random salt. */
105
+ export function generateSalt() {
106
+ return randomBytes(ARGON2_SALT_LEN);
107
+ }
108
+ /** Parse a hex-encoded salt string into bytes. */
109
+ export function hexToBytes(hex) {
110
+ const normalized = hex.trim();
111
+ if (normalized.length % 2 !== 0) {
112
+ throw new Error('Invalid hex string length');
113
+ }
114
+ const bytes = new Uint8Array(normalized.length / 2);
115
+ for (let i = 0; i < bytes.length; i += 1) {
116
+ bytes[i] = Number.parseInt(normalized.slice(i * 2, i * 2 + 2), 16);
117
+ }
118
+ return bytes;
119
+ }
120
+ /** Encode bytes as a lowercase hex string. */
121
+ export function bytesToHex(bytes) {
122
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
123
+ }
124
+ /** Best-effort RAM wipe for sensitive byte buffers. */
125
+ export function zeroBytes(data) {
126
+ const view = data instanceof Uint8Array ? data : new Uint8Array(data);
127
+ for (let i = 0; i < view.length; i += 1) {
128
+ view[i] = 0;
129
+ }
130
+ }
131
+ /** SHA-256(ciphertext_b64 || nonce_b64 || auth_tag_b64) for Gateway L11 verification. */
132
+ export async function computePayloadHash(envelope) {
133
+ const payload = new TextEncoder().encode(envelope.ciphertext + envelope.nonce + envelope.auth_tag);
134
+ return sha256Hex(payload);
135
+ }
136
+ /** Full Zero-Knowledge envelope encryption (derives KEK from password). */
137
+ export async function encryptDocument(plaintext, password, salt) {
138
+ const kek = await deriveKek(password, salt);
139
+ const dek = randomBytes(DEK_SIZE_BYTES);
140
+ try {
141
+ const docNonce = randomBytes(NONCE_SIZE_BYTES);
142
+ const { ciphertext: docCiphertext, authTag } = await aesGcmEncrypt(dek, docNonce, plaintext);
143
+ const dekNonce = randomBytes(NONCE_SIZE_BYTES);
144
+ const encryptedDek = await aesGcmEncryptCombined(kek, dekNonce, dek);
145
+ return await buildEnvelopeFromParts(docCiphertext, docNonce, authTag, encryptedDek, dekNonce);
146
+ }
147
+ finally {
148
+ zeroBytes(dek);
149
+ zeroBytes(kek);
150
+ }
151
+ }
152
+ async function aesGcmEncryptCombined(keyBytes, nonce, plaintext) {
153
+ const key = await importAesKey(keyBytes, ['encrypt']);
154
+ return new Uint8Array(await getSubtleCrypto().encrypt({ name: 'AES-GCM', iv: toBufferSource(nonce), tagLength: 128 }, key, toBufferSource(plaintext)));
155
+ }
156
+ /** Encrypt using a pre-derived KEK (skips Argon2id). */
157
+ export async function encryptDocumentWithKek(plaintext, kek) {
158
+ const dek = randomBytes(DEK_SIZE_BYTES);
159
+ try {
160
+ const docNonce = randomBytes(NONCE_SIZE_BYTES);
161
+ const { ciphertext: docCiphertext, authTag } = await aesGcmEncrypt(dek, docNonce, plaintext);
162
+ const dekNonce = randomBytes(NONCE_SIZE_BYTES);
163
+ const encryptedDek = await aesGcmEncryptCombined(kek, dekNonce, dek);
164
+ return await buildEnvelopeFromParts(docCiphertext, docNonce, authTag, encryptedDek, dekNonce);
165
+ }
166
+ finally {
167
+ zeroBytes(dek);
168
+ }
169
+ }
170
+ /** Reverse envelope encryption using password-derived KEK. */
171
+ export async function decryptDocument(envelope, password, salt) {
172
+ const kek = await deriveKek(password, salt);
173
+ try {
174
+ return await decryptDocumentWithKek(envelope, kek);
175
+ }
176
+ finally {
177
+ zeroBytes(kek);
178
+ }
179
+ }
180
+ /** Decrypt using a pre-derived KEK (skips Argon2id). */
181
+ export async function decryptDocumentWithKek(envelope, kek) {
182
+ const dek = await aesGcmDecryptCombined(kek, base64ToBytes(envelope.dek_nonce), base64ToBytes(envelope.encrypted_dek));
183
+ try {
184
+ return aesGcmDecrypt(dek, base64ToBytes(envelope.nonce), base64ToBytes(envelope.ciphertext), base64ToBytes(envelope.auth_tag));
185
+ }
186
+ finally {
187
+ zeroBytes(dek);
188
+ }
189
+ }