@makolabs/ripple 3.8.0 → 3.9.1

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 (42) hide show
  1. package/dist/ai/MessageBox.svelte +2 -1
  2. package/dist/button/Button.svelte +89 -7
  3. package/dist/elements/file-viewer/FilePreviewModal.svelte +102 -0
  4. package/dist/elements/file-viewer/FilePreviewModal.svelte.d.ts +4 -0
  5. package/dist/elements/file-viewer/FileViewer.svelte +96 -0
  6. package/dist/elements/file-viewer/FileViewer.svelte.d.ts +4 -0
  7. package/dist/elements/file-viewer/file-preview-modal-types.d.ts +62 -0
  8. package/dist/elements/file-viewer/file-preview-modal-types.js +1 -0
  9. package/dist/elements/file-viewer/file-viewer-types.d.ts +51 -0
  10. package/dist/elements/file-viewer/file-viewer-types.js +127 -0
  11. package/dist/elements/image-viewer/ImageViewer.svelte +133 -0
  12. package/dist/elements/image-viewer/ImageViewer.svelte.d.ts +4 -0
  13. package/dist/elements/image-viewer/image-viewer-types.d.ts +16 -0
  14. package/dist/elements/image-viewer/image-viewer-types.js +1 -0
  15. package/dist/elements/pdf-viewer/PdfViewer.svelte +220 -34
  16. package/dist/elements/pdf-viewer/pdf-viewer-types.d.ts +13 -4
  17. package/dist/elements/text-viewer/TextViewer.svelte +141 -0
  18. package/dist/elements/text-viewer/TextViewer.svelte.d.ts +4 -0
  19. package/dist/elements/text-viewer/text-viewer-types.d.ts +10 -0
  20. package/dist/elements/text-viewer/text-viewer-types.js +1 -0
  21. package/dist/elements/viewer-shared/UnsupportedPreview.svelte +84 -0
  22. package/dist/elements/viewer-shared/UnsupportedPreview.svelte.d.ts +4 -0
  23. package/dist/elements/viewer-shared/ViewerToolbar.svelte +128 -0
  24. package/dist/elements/viewer-shared/ViewerToolbar.svelte.d.ts +4 -0
  25. package/dist/elements/viewer-shared/unsupported-preview-types.d.ts +17 -0
  26. package/dist/elements/viewer-shared/unsupported-preview-types.js +1 -0
  27. package/dist/elements/viewer-shared/viewer-toolbar-types.d.ts +14 -0
  28. package/dist/elements/viewer-shared/viewer-toolbar-types.js +1 -0
  29. package/dist/funcs/clerk-requests.d.ts +34 -0
  30. package/dist/funcs/clerk-requests.js +38 -0
  31. package/dist/funcs/user-management.remote.js +34 -43
  32. package/dist/helper/is-safe-http-url.d.ts +33 -0
  33. package/dist/helper/is-safe-http-url.js +214 -0
  34. package/dist/helper/pdf-safety.d.ts +38 -0
  35. package/dist/helper/pdf-safety.js +85 -0
  36. package/dist/helper/resource-limits.d.ts +13 -0
  37. package/dist/helper/resource-limits.js +16 -0
  38. package/dist/helper/sanitize-html.d.ts +5 -0
  39. package/dist/helper/sanitize-html.js +12 -0
  40. package/dist/index.d.ts +9 -0
  41. package/dist/index.js +8 -0
  42. package/package.json +4 -2
@@ -0,0 +1,141 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte';
3
+ import { cn } from '../../helper/cls.js';
4
+ import { buildTestId } from '../../helper/testid.js';
5
+ import { isSafeHttpUrl, redactUrl } from '../../helper/is-safe-http-url.js';
6
+ import { MAX_TEXT_BYTES } from '../../helper/resource-limits.js';
7
+ import Spinner from '../spinner/Spinner.svelte';
8
+ import UnsupportedPreview from '../viewer-shared/UnsupportedPreview.svelte';
9
+ import { Color, Size } from '../../variants.js';
10
+ import type { TextViewerProps } from './text-viewer-types.js';
11
+
12
+ let {
13
+ url,
14
+ maxBytes = MAX_TEXT_BYTES,
15
+ loadingLabel = 'Loading file...',
16
+ errorTitle = 'Unable to display file',
17
+ errorActionLabel = 'Open in new tab',
18
+ class: className = '',
19
+ testId
20
+ }: TextViewerProps = $props();
21
+
22
+ // Allow same-origin (incl. dev localhost) OR a public cross-origin host.
23
+ // Block cross-origin private/loopback/metadata hosts (SSRF read-exfil).
24
+ const urlAllowed = $derived(
25
+ isSafeHttpUrl(url, { sameOrigin: true, allowPrivateHosts: true }) ||
26
+ isSafeHttpUrl(url, { allowPrivateHosts: false })
27
+ );
28
+
29
+ let status = $state<'loading' | 'ready' | 'error'>('loading');
30
+ let content = $state('');
31
+ let truncated = $state(false);
32
+ let controller: AbortController | null = null;
33
+
34
+ async function load(active: AbortController) {
35
+ if (!urlAllowed) {
36
+ status = 'error';
37
+ return;
38
+ }
39
+
40
+ try {
41
+ const res = await fetch(url, {
42
+ signal: active.signal,
43
+ redirect: 'error',
44
+ credentials: 'same-origin'
45
+ });
46
+ if (!res.ok || !res.body) throw new Error('bad response');
47
+
48
+ const reader = res.body.getReader();
49
+ const chunks: Uint8Array[] = [];
50
+ let received = 0;
51
+ let didTruncate = false;
52
+
53
+ for (;;) {
54
+ const { done, value } = await reader.read();
55
+ if (done) break;
56
+ if (!value) continue;
57
+ chunks.push(value);
58
+ received += value.byteLength;
59
+ if (received > maxBytes) {
60
+ await reader.cancel();
61
+ didTruncate = true;
62
+ break;
63
+ }
64
+ }
65
+
66
+ const total = chunks.reduce((n, c) => n + c.byteLength, 0);
67
+ const buffer = new Uint8Array(total);
68
+ let offset = 0;
69
+ for (const chunk of chunks) {
70
+ buffer.set(chunk, offset);
71
+ offset += chunk.byteLength;
72
+ }
73
+
74
+ // A newer load() may have superseded this one while we were reading;
75
+ // never write state for a stale (aborted) request.
76
+ if (active.signal.aborted) return;
77
+ content = new TextDecoder().decode(buffer.subarray(0, maxBytes));
78
+ truncated = didTruncate;
79
+ status = 'ready';
80
+ } catch (err) {
81
+ if (err instanceof Error && err.name === 'AbortError') return;
82
+ if (active.signal.aborted) return;
83
+ console.error(
84
+ 'Error loading text:',
85
+ redactUrl(url),
86
+ err instanceof Error ? err.name : 'unknown'
87
+ );
88
+ status = 'error';
89
+ }
90
+ }
91
+
92
+ $effect(() => {
93
+ // re-run when url changes
94
+ void url;
95
+ // Abort any in-flight load before starting a new one so a late-resolving
96
+ // previous request can't write content for the wrong file.
97
+ controller?.abort();
98
+ status = 'loading';
99
+ content = '';
100
+ truncated = false;
101
+ const active = new AbortController();
102
+ controller = active;
103
+ load(active);
104
+ return () => active.abort();
105
+ });
106
+
107
+ onDestroy(() => {
108
+ controller?.abort();
109
+ });
110
+ </script>
111
+
112
+ <div
113
+ class={cn('bg-default-100 flex h-full flex-col', className)}
114
+ data-testid={buildTestId('text-viewer', undefined, testId)}
115
+ >
116
+ {#if status === 'error'}
117
+ <UnsupportedPreview
118
+ url={urlAllowed ? url : undefined}
119
+ title={errorTitle}
120
+ actionLabel={errorActionLabel}
121
+ variant="error"
122
+ {testId}
123
+ />
124
+ {:else if status === 'loading'}
125
+ <div class="flex flex-1 items-center justify-center">
126
+ <Spinner size={Size.LG} color={Color.PRIMARY} label={loadingLabel} />
127
+ </div>
128
+ {:else}
129
+ {#if truncated}
130
+ <div
131
+ class="border-default-200 bg-default-50 text-default-700 border-b px-4 py-2 text-sm"
132
+ data-testid={buildTestId('text-viewer', 'truncated', testId)}
133
+ >
134
+ Showing the first {Math.round(maxBytes / 1024)} KB of this file.
135
+ </div>
136
+ {/if}
137
+ <pre
138
+ class="text-default-900 flex-1 overflow-auto p-4 font-mono text-sm [overflow-wrap:anywhere] whitespace-pre-wrap"
139
+ data-testid={buildTestId('text-viewer', 'content', testId)}>{content}</pre>
140
+ {/if}
141
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { TextViewerProps } from './text-viewer-types.js';
2
+ declare const TextViewer: import("svelte").Component<TextViewerProps, {}, "">;
3
+ type TextViewer = ReturnType<typeof TextViewer>;
4
+ export default TextViewer;
@@ -0,0 +1,10 @@
1
+ import type { ClassValue } from 'tailwind-variants';
2
+ export type TextViewerProps = {
3
+ url: string;
4
+ maxBytes?: number;
5
+ loadingLabel?: string;
6
+ errorTitle?: string;
7
+ errorActionLabel?: string;
8
+ class?: ClassValue;
9
+ testId?: string;
10
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,84 @@
1
+ <script lang="ts">
2
+ import { cn } from '../../helper/cls.js';
3
+ import { buildTestId } from '../../helper/testid.js';
4
+ import Button from '../../button/Button.svelte';
5
+ import { Color, Size } from '../../variants.js';
6
+ import type { UnsupportedPreviewProps } from './unsupported-preview-types.js';
7
+
8
+ let {
9
+ url,
10
+ fileName,
11
+ title = 'Preview not available',
12
+ message,
13
+ actionLabel = 'Open in new tab',
14
+ variant = 'info',
15
+ class: className = '',
16
+ testId
17
+ }: UnsupportedPreviewProps = $props();
18
+ </script>
19
+
20
+ <div
21
+ class={cn('flex h-full w-full items-center justify-center p-6', className)}
22
+ data-testid={buildTestId('unsupported-preview', undefined, testId)}
23
+ >
24
+ <div class="ring-default-200 max-w-md rounded-xl bg-white p-6 text-center shadow-sm ring-1">
25
+ <div
26
+ class={cn(
27
+ 'mx-auto mb-3 flex size-10 items-center justify-center rounded-full',
28
+ variant === 'error' ? 'bg-danger-50 text-danger-600' : 'bg-default-100 text-default-600'
29
+ )}
30
+ aria-hidden="true"
31
+ >
32
+ {#if variant === 'error'}
33
+ <svg
34
+ xmlns="http://www.w3.org/2000/svg"
35
+ class="h-5 w-5"
36
+ viewBox="0 0 24 24"
37
+ fill="none"
38
+ stroke="currentColor"
39
+ stroke-width="2"
40
+ stroke-linecap="round"
41
+ stroke-linejoin="round"
42
+ >
43
+ <circle cx="12" cy="12" r="10" />
44
+ <line x1="12" y1="8" x2="12" y2="12" />
45
+ <line x1="12" y1="16" x2="12.01" y2="16" />
46
+ </svg>
47
+ {:else}
48
+ <svg
49
+ xmlns="http://www.w3.org/2000/svg"
50
+ class="h-5 w-5"
51
+ viewBox="0 0 24 24"
52
+ fill="none"
53
+ stroke="currentColor"
54
+ stroke-width="2"
55
+ stroke-linecap="round"
56
+ stroke-linejoin="round"
57
+ >
58
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
59
+ <polyline points="14 2 14 8 20 8" />
60
+ </svg>
61
+ {/if}
62
+ </div>
63
+
64
+ <p class="text-default-900 mb-1 text-base font-semibold">{title}</p>
65
+ {#if fileName}
66
+ <p class="text-default-700 mb-1 text-sm break-all">{fileName}</p>
67
+ {/if}
68
+ {#if message}
69
+ <p class="text-default-600 mb-4 text-sm">{message}</p>
70
+ {/if}
71
+
72
+ {#if url}
73
+ <Button
74
+ color={Color.PRIMARY}
75
+ size={Size.SM}
76
+ href={url}
77
+ target="_blank"
78
+ rel="noopener noreferrer"
79
+ >
80
+ {actionLabel}
81
+ </Button>
82
+ {/if}
83
+ </div>
84
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { UnsupportedPreviewProps } from './unsupported-preview-types.js';
2
+ declare const UnsupportedPreview: import("svelte").Component<UnsupportedPreviewProps, {}, "">;
3
+ type UnsupportedPreview = ReturnType<typeof UnsupportedPreview>;
4
+ export default UnsupportedPreview;
@@ -0,0 +1,128 @@
1
+ <script lang="ts">
2
+ import { buildTestId } from '../../helper/testid.js';
3
+ import type { ViewerToolbarProps } from './viewer-toolbar-types.js';
4
+
5
+ let {
6
+ component = 'viewer',
7
+ scale,
8
+ minScale,
9
+ maxScale,
10
+ disabled = false,
11
+ leftLabel = '',
12
+ onzoomin: onZoomIn,
13
+ onzoomout: onZoomOut,
14
+ onfittowidth: onFitToWidth,
15
+ testId
16
+ }: ViewerToolbarProps = $props();
17
+
18
+ function handleZoomIn() {
19
+ onZoomIn?.();
20
+ }
21
+ function handleZoomOut() {
22
+ onZoomOut?.();
23
+ }
24
+ function handleFitToWidth() {
25
+ onFitToWidth?.();
26
+ }
27
+
28
+ const btnClass =
29
+ 'text-default-700 hover:bg-default-100 focus-visible:ring-primary-400 rounded-md p-1.5 transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40';
30
+ </script>
31
+
32
+ <div
33
+ class="border-default-200 bg-default-50 flex items-center justify-between border-b px-4 py-2"
34
+ data-testid={buildTestId(component, 'toolbar', testId)}
35
+ >
36
+ <div class="flex items-center gap-2">
37
+ <span class="text-default-700 text-sm" aria-live="polite">{leftLabel}</span>
38
+ </div>
39
+
40
+ <div class="flex items-center gap-1">
41
+ <button
42
+ type="button"
43
+ onclick={handleZoomOut}
44
+ disabled={disabled || scale <= minScale}
45
+ class={btnClass}
46
+ aria-label="Zoom out"
47
+ title="Zoom out"
48
+ data-testid={buildTestId(component, 'zoom-out', testId)}
49
+ >
50
+ <svg
51
+ xmlns="http://www.w3.org/2000/svg"
52
+ class="h-5 w-5"
53
+ viewBox="0 0 24 24"
54
+ fill="none"
55
+ stroke="currentColor"
56
+ stroke-width="2"
57
+ stroke-linecap="round"
58
+ stroke-linejoin="round"
59
+ aria-hidden="true"
60
+ >
61
+ <circle cx="11" cy="11" r="8" />
62
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
63
+ <line x1="8" y1="11" x2="14" y2="11" />
64
+ </svg>
65
+ </button>
66
+
67
+ <span
68
+ class="text-default-700 min-w-[3.5rem] text-center text-sm tabular-nums"
69
+ aria-live="polite"
70
+ data-testid={buildTestId(component, 'scale', testId)}
71
+ >
72
+ {Math.round(scale * 100)}%
73
+ </span>
74
+
75
+ <button
76
+ type="button"
77
+ onclick={handleZoomIn}
78
+ disabled={disabled || scale >= maxScale}
79
+ class={btnClass}
80
+ aria-label="Zoom in"
81
+ title="Zoom in"
82
+ data-testid={buildTestId(component, 'zoom-in', testId)}
83
+ >
84
+ <svg
85
+ xmlns="http://www.w3.org/2000/svg"
86
+ class="h-5 w-5"
87
+ viewBox="0 0 24 24"
88
+ fill="none"
89
+ stroke="currentColor"
90
+ stroke-width="2"
91
+ stroke-linecap="round"
92
+ stroke-linejoin="round"
93
+ aria-hidden="true"
94
+ >
95
+ <circle cx="11" cy="11" r="8" />
96
+ <line x1="11" y1="8" x2="11" y2="14" />
97
+ <line x1="8" y1="11" x2="14" y2="11" />
98
+ </svg>
99
+ </button>
100
+
101
+ <button
102
+ type="button"
103
+ onclick={handleFitToWidth}
104
+ {disabled}
105
+ class={btnClass}
106
+ aria-label="Fit to width"
107
+ title="Fit to width"
108
+ data-testid={buildTestId(component, 'fit-to-width', testId)}
109
+ >
110
+ <svg
111
+ xmlns="http://www.w3.org/2000/svg"
112
+ class="h-5 w-5"
113
+ viewBox="0 0 24 24"
114
+ fill="none"
115
+ stroke="currentColor"
116
+ stroke-width="2"
117
+ stroke-linecap="round"
118
+ stroke-linejoin="round"
119
+ aria-hidden="true"
120
+ >
121
+ <polyline points="15 3 21 3 21 9" />
122
+ <polyline points="9 21 3 21 3 15" />
123
+ <line x1="21" y1="3" x2="14" y2="10" />
124
+ <line x1="3" y1="21" x2="10" y2="14" />
125
+ </svg>
126
+ </button>
127
+ </div>
128
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { ViewerToolbarProps } from './viewer-toolbar-types.js';
2
+ declare const ViewerToolbar: import("svelte").Component<ViewerToolbarProps, {}, "">;
3
+ type ViewerToolbar = ReturnType<typeof ViewerToolbar>;
4
+ export default ViewerToolbar;
@@ -0,0 +1,17 @@
1
+ import type { ClassValue } from 'tailwind-variants';
2
+ export type UnsupportedPreviewProps = {
3
+ /** When set, an "open/download" link is shown (scheme-guarded by Button). */
4
+ url?: string;
5
+ /** File name shown above the message (rendered as text). */
6
+ fileName?: string;
7
+ /** Card heading. @default 'Preview not available' */
8
+ title?: string;
9
+ /** Supporting line under the heading. */
10
+ message?: string;
11
+ /** Label of the open/download link. @default 'Open in new tab' */
12
+ actionLabel?: string;
13
+ /** Visual treatment. @default 'info' */
14
+ variant?: 'info' | 'error';
15
+ class?: ClassValue;
16
+ testId?: string;
17
+ };
@@ -0,0 +1,14 @@
1
+ export type ViewerToolbarProps = {
2
+ /** testid namespace, e.g. 'image-viewer'. @default 'viewer' */
3
+ component?: string;
4
+ scale: number;
5
+ minScale: number;
6
+ maxScale: number;
7
+ disabled?: boolean;
8
+ /** Optional left-aligned label (e.g. a page or dimension hint). */
9
+ leftLabel?: string;
10
+ onzoomin?: () => void;
11
+ onzoomout?: () => void;
12
+ onfittowidth?: () => void;
13
+ testId?: string;
14
+ };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Pure builders for Clerk user-API requests, extracted from
3
+ * `user-management.remote.ts` so the request shape is unit-testable — a
4
+ * `*.remote.ts` module cannot be imported in vitest (SvelteKit's plugin rejects
5
+ * any non-remote export).
6
+ *
7
+ * They also document the rule that caused minted API keys to silently never
8
+ * persist: Clerk deprecated sending `private_metadata` in the body of
9
+ * `PATCH /users/{id}` (it now returns 422 `form_param_deprecated`). User
10
+ * metadata must be written through the dedicated `PATCH /users/{id}/metadata`
11
+ * endpoint, which merges into the existing metadata.
12
+ */
13
+ export interface ClerkRequest {
14
+ endpoint: string;
15
+ init: {
16
+ method: string;
17
+ body: string;
18
+ };
19
+ }
20
+ /**
21
+ * Build a request that persists `privateMetadata` on a Clerk user via the
22
+ * dedicated metadata endpoint. Clerk merges the provided keys into the user's
23
+ * existing private metadata.
24
+ */
25
+ export declare function clerkMetadataPatch(userId: string, privateMetadata: Record<string, unknown>): ClerkRequest;
26
+ /**
27
+ * Build the body for a profile update (`PATCH /users/{id}`). Metadata is
28
+ * intentionally excluded — it must go through {@link clerkMetadataPatch}.
29
+ */
30
+ export declare function clerkProfilePatchBody(userData: {
31
+ first_name?: string;
32
+ last_name?: string;
33
+ username?: string;
34
+ }): Record<string, string>;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pure builders for Clerk user-API requests, extracted from
3
+ * `user-management.remote.ts` so the request shape is unit-testable — a
4
+ * `*.remote.ts` module cannot be imported in vitest (SvelteKit's plugin rejects
5
+ * any non-remote export).
6
+ *
7
+ * They also document the rule that caused minted API keys to silently never
8
+ * persist: Clerk deprecated sending `private_metadata` in the body of
9
+ * `PATCH /users/{id}` (it now returns 422 `form_param_deprecated`). User
10
+ * metadata must be written through the dedicated `PATCH /users/{id}/metadata`
11
+ * endpoint, which merges into the existing metadata.
12
+ */
13
+ /**
14
+ * Build a request that persists `privateMetadata` on a Clerk user via the
15
+ * dedicated metadata endpoint. Clerk merges the provided keys into the user's
16
+ * existing private metadata.
17
+ */
18
+ export function clerkMetadataPatch(userId, privateMetadata) {
19
+ return {
20
+ endpoint: `/users/${userId}/metadata`,
21
+ init: { method: 'PATCH', body: JSON.stringify({ private_metadata: privateMetadata }) }
22
+ };
23
+ }
24
+ /**
25
+ * Build the body for a profile update (`PATCH /users/{id}`). Metadata is
26
+ * intentionally excluded — it must go through {@link clerkMetadataPatch}.
27
+ */
28
+ export function clerkProfilePatchBody(userData) {
29
+ const body = {};
30
+ if (userData.first_name !== undefined)
31
+ body.first_name = userData.first_name;
32
+ if (userData.last_name !== undefined)
33
+ body.last_name = userData.last_name;
34
+ if (userData.username !== undefined && userData.username !== '') {
35
+ body.username = userData.username;
36
+ }
37
+ return body;
38
+ }
@@ -2,6 +2,7 @@ import { command } from '$app/server';
2
2
  import { getRequestEvent } from '$app/server';
3
3
  import { env } from '$env/dynamic/private';
4
4
  import { building } from '$app/environment';
5
+ import { clerkMetadataPatch, clerkProfilePatchBody } from './clerk-requests.js';
5
6
  const CLIENT_ID = env.CLIENT_ID;
6
7
  const ORGANIZATION_ID = env.ALLOWED_ORG_ID;
7
8
  if (!CLIENT_ID && !building) {
@@ -248,6 +249,16 @@ async function createUserPermissions(email, permissions, clientId = CLIENT_ID) {
248
249
  log.trace('createUserPermissions', 'Result:', result);
249
250
  return result;
250
251
  }
252
+ /**
253
+ * Persist private metadata on a Clerk user via the dedicated metadata endpoint.
254
+ * Clerk deprecated `private_metadata` in the body of `PATCH /users/{id}` (it
255
+ * now returns 422 form_param_deprecated), which silently dropped minted API
256
+ * keys. Routes through `PATCH /users/{id}/metadata` (which merges) instead.
257
+ */
258
+ async function writeClerkMetadata(userId, privateMetadata) {
259
+ const { endpoint, init } = clerkMetadataPatch(userId, privateMetadata);
260
+ return makeClerkRequest(endpoint, init);
261
+ }
251
262
  export const getUsers = command('unchecked', async (options) => {
252
263
  log.trace('getUsers', 'Called with options:', options);
253
264
  try {
@@ -347,14 +358,9 @@ export const createUser = command('unchecked', async (userData) => {
347
358
  const apiKey = adminKeyResult?.data?.key;
348
359
  log.trace('createUser', 'Admin key created, has key:', !!apiKey);
349
360
  if (adminKeyResult && apiKey) {
350
- const updatedUser = await makeClerkRequest(`/users/${result.id}`, {
351
- method: 'PATCH',
352
- body: JSON.stringify({
353
- private_metadata: {
354
- ...result.private_metadata,
355
- mako_api_key: apiKey
356
- }
357
- })
361
+ const updatedUser = await writeClerkMetadata(result.id, {
362
+ ...result.private_metadata,
363
+ mako_api_key: apiKey
358
364
  });
359
365
  // Ensure updatedUser is serializable
360
366
  result = JSON.parse(JSON.stringify(updatedUser));
@@ -385,22 +391,19 @@ export const updateUser = command('unchecked', async (options) => {
385
391
  const { userId, userData } = options;
386
392
  log.trace('updateUser', 'Called for userId:', userId, 'fields:', Object.keys(userData));
387
393
  try {
388
- const updateData = {};
389
- if (userData.first_name !== undefined)
390
- updateData.first_name = userData.first_name;
391
- if (userData.last_name !== undefined)
392
- updateData.last_name = userData.last_name;
393
- if (userData.username !== undefined && userData.username !== '') {
394
- updateData.username = userData.username;
395
- }
394
+ // Profile fields and metadata live behind different Clerk endpoints —
395
+ // `private_metadata` is no longer accepted on PATCH /users/{id}.
396
+ const profileBody = clerkProfilePatchBody(userData);
397
+ log.trace('updateUser', 'Profile payload:', profileBody);
398
+ let result = Object.keys(profileBody).length > 0
399
+ ? await makeClerkRequest(`/users/${userId}`, {
400
+ method: 'PATCH',
401
+ body: JSON.stringify(profileBody)
402
+ })
403
+ : await makeClerkRequest(`/users/${userId}`);
396
404
  if (userData.private_metadata !== undefined) {
397
- updateData.private_metadata = userData.private_metadata;
405
+ result = await writeClerkMetadata(userId, userData.private_metadata);
398
406
  }
399
- log.trace('updateUser', 'Update payload:', updateData);
400
- let result = await makeClerkRequest(`/users/${userId}`, {
401
- method: 'PATCH',
402
- body: JSON.stringify(updateData)
403
- });
404
407
  // Ensure result is serializable
405
408
  result = JSON.parse(JSON.stringify(result));
406
409
  if (userData.permissions !== undefined) {
@@ -572,14 +575,9 @@ export const updateUserPermissions = command('unchecked', async (options) => {
572
575
  const newKeyResult = await createUserPermissions(email, permissions);
573
576
  const newApiKey = newKeyResult?.data?.key;
574
577
  if (newApiKey) {
575
- await makeClerkRequest(`/users/${userId}`, {
576
- method: 'PATCH',
577
- body: JSON.stringify({
578
- private_metadata: {
579
- ...(user.private_metadata || {}),
580
- mako_api_key: newApiKey
581
- }
582
- })
578
+ await writeClerkMetadata(userId, {
579
+ ...(user.private_metadata || {}),
580
+ mako_api_key: newApiKey
583
581
  });
584
582
  log.trace('updateUserPermissions', 'New key stored in Clerk metadata');
585
583
  }
@@ -742,14 +740,9 @@ export const generateApiKey = command('unchecked', async (options) => {
742
740
  currentUser = await makeClerkRequest(`/users/${options.userId}`);
743
741
  }
744
742
  if (currentUser) {
745
- await makeClerkRequest(`/users/${options.userId}`, {
746
- method: 'PATCH',
747
- body: JSON.stringify({
748
- private_metadata: {
749
- ...(currentUser.private_metadata || {}),
750
- mako_api_key: newApiKey
751
- }
752
- })
743
+ await writeClerkMetadata(options.userId, {
744
+ ...(currentUser.private_metadata || {}),
745
+ mako_api_key: newApiKey
753
746
  });
754
747
  log.trace('generateApiKey', 'Clerk metadata updated with new key');
755
748
  }
@@ -874,11 +867,9 @@ export const approveUser = command('unchecked', async (input) => {
874
867
  if (!apiKey) {
875
868
  throw new Error('Failed to mint API key during approval');
876
869
  }
877
- await makeClerkRequest(`/users/${input.userId}`, {
878
- method: 'PATCH',
879
- body: JSON.stringify({
880
- private_metadata: { ...(user.private_metadata || {}), mako_api_key: apiKey }
881
- })
870
+ await writeClerkMetadata(input.userId, {
871
+ ...(user.private_metadata || {}),
872
+ mako_api_key: apiKey
882
873
  });
883
874
  log.trace('approveUser', 'Approved and key issued');
884
875
  return JSON.parse(JSON.stringify({ apiKey }));
@@ -0,0 +1,33 @@
1
+ /**
2
+ * URL safety helpers for the file-preview viewers. See spec §4 / threat
3
+ * register T0.1, T1.3, T1.4. `isSafeHttpUrl` is a strict allowlist; do not
4
+ * convert it to a denylist.
5
+ */
6
+ /** True for loopback / link-local / private / metadata / encoded-IP hosts. */
7
+ export declare function isBlockedHost(hostname: string): boolean;
8
+ export type SafeUrlOptions = {
9
+ /** Base for resolving relative URLs. Defaults to document.baseURI in a browser. */
10
+ base?: string;
11
+ /** Allow same-origin blob: URLs. */
12
+ allowBlob?: boolean;
13
+ /** Require the resolved origin to be the base origin (or in allowedOrigins). */
14
+ sameOrigin?: boolean;
15
+ /** Extra origins permitted when sameOrigin is set. */
16
+ allowedOrigins?: string[];
17
+ /**
18
+ * Waive private/loopback/metadata host blocking, but ONLY for the base
19
+ * (same-origin) origin or an entry in `allowedOrigins`. This is not a blanket
20
+ * SSRF bypass: cross-origin private hosts stay blocked even when set. Intended
21
+ * for same-origin dev fetches (e.g. a localhost PDF served by the app itself).
22
+ */
23
+ allowPrivateHosts?: boolean;
24
+ };
25
+ /** Strict allowlist: http/https (+ same-origin blob when allowBlob). */
26
+ export declare function isSafeHttpUrl(url: string, opts?: SafeUrlOptions): boolean;
27
+ /** Strip query string + hash for safe logging (signed-URL tokens live there). */
28
+ export declare function redactUrl(url: string, base?: string): string;
29
+ /**
30
+ * Link-href guard for navigation (`<a href>`). Broader than isSafeHttpUrl:
31
+ * allows http/https/mailto/tel/relative/hash, blocks only executable schemes.
32
+ */
33
+ export declare function isSafeLinkHref(href: string, base?: string): boolean;