@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.
- package/dist/ai/MessageBox.svelte +2 -1
- package/dist/button/Button.svelte +89 -7
- package/dist/elements/file-viewer/FilePreviewModal.svelte +102 -0
- package/dist/elements/file-viewer/FilePreviewModal.svelte.d.ts +4 -0
- package/dist/elements/file-viewer/FileViewer.svelte +96 -0
- package/dist/elements/file-viewer/FileViewer.svelte.d.ts +4 -0
- package/dist/elements/file-viewer/file-preview-modal-types.d.ts +62 -0
- package/dist/elements/file-viewer/file-preview-modal-types.js +1 -0
- package/dist/elements/file-viewer/file-viewer-types.d.ts +51 -0
- package/dist/elements/file-viewer/file-viewer-types.js +127 -0
- package/dist/elements/image-viewer/ImageViewer.svelte +133 -0
- package/dist/elements/image-viewer/ImageViewer.svelte.d.ts +4 -0
- package/dist/elements/image-viewer/image-viewer-types.d.ts +16 -0
- package/dist/elements/image-viewer/image-viewer-types.js +1 -0
- package/dist/elements/pdf-viewer/PdfViewer.svelte +220 -34
- package/dist/elements/pdf-viewer/pdf-viewer-types.d.ts +13 -4
- package/dist/elements/text-viewer/TextViewer.svelte +141 -0
- package/dist/elements/text-viewer/TextViewer.svelte.d.ts +4 -0
- package/dist/elements/text-viewer/text-viewer-types.d.ts +10 -0
- package/dist/elements/text-viewer/text-viewer-types.js +1 -0
- package/dist/elements/viewer-shared/UnsupportedPreview.svelte +84 -0
- package/dist/elements/viewer-shared/UnsupportedPreview.svelte.d.ts +4 -0
- package/dist/elements/viewer-shared/ViewerToolbar.svelte +128 -0
- package/dist/elements/viewer-shared/ViewerToolbar.svelte.d.ts +4 -0
- package/dist/elements/viewer-shared/unsupported-preview-types.d.ts +17 -0
- package/dist/elements/viewer-shared/unsupported-preview-types.js +1 -0
- package/dist/elements/viewer-shared/viewer-toolbar-types.d.ts +14 -0
- package/dist/elements/viewer-shared/viewer-toolbar-types.js +1 -0
- package/dist/funcs/clerk-requests.d.ts +34 -0
- package/dist/funcs/clerk-requests.js +38 -0
- package/dist/funcs/user-management.remote.js +34 -43
- package/dist/helper/is-safe-http-url.d.ts +33 -0
- package/dist/helper/is-safe-http-url.js +214 -0
- package/dist/helper/pdf-safety.d.ts +38 -0
- package/dist/helper/pdf-safety.js +85 -0
- package/dist/helper/resource-limits.d.ts +13 -0
- package/dist/helper/resource-limits.js +16 -0
- package/dist/helper/sanitize-html.d.ts +5 -0
- package/dist/helper/sanitize-html.js +12 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- 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 @@
|
|
|
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,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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
|
576
|
-
|
|
577
|
-
|
|
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
|
|
746
|
-
|
|
747
|
-
|
|
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
|
|
878
|
-
|
|
879
|
-
|
|
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;
|