@elytracms/studio 0.0.64 → 0.0.66
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/convex/assets.ts +11 -0
- package/convex/delivery.ts +5 -0
- package/convex/schema.ts +23 -0
- package/dist/components/account/self-account-view-source.js +9 -2
- package/dist/components/account/self-account-view-source.js.map +1 -1
- package/dist/components/app-views/default-modules.d.ts +28 -1
- package/dist/components/app-views/default-modules.js +47 -2
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/assets/asset-thumb.d.ts +4 -2
- package/dist/components/assets/asset-thumb.js +26 -4
- package/dist/components/assets/asset-thumb.js.map +1 -1
- package/dist/components/assets/assets-module.js +48 -2
- package/dist/components/assets/assets-module.js.map +1 -1
- package/dist/components/assets/assets-store.d.ts +11 -1
- package/dist/components/assets/assets-store.js +18 -1
- package/dist/components/assets/assets-store.js.map +1 -1
- package/dist/components/auth-context.js +32 -1
- package/dist/components/auth-context.js.map +1 -1
- package/dist/components/builder/blocks-model.d.ts +11 -0
- package/dist/components/builder/blocks-model.js +30 -10
- package/dist/components/builder/blocks-model.js.map +1 -1
- package/dist/components/builder/canvas-chrome-css.js +90 -8
- package/dist/components/builder/canvas-chrome-css.js.map +1 -1
- package/dist/components/builder/canvas-dnd.js +4 -2
- package/dist/components/builder/canvas-dnd.js.map +1 -1
- package/dist/components/builder/canvas-inline-editing.d.ts +7 -0
- package/dist/components/builder/canvas-inline-editing.js +3 -1
- package/dist/components/builder/canvas-inline-editing.js.map +1 -1
- package/dist/components/builder/canvas-overlay-geometry.d.ts +72 -0
- package/dist/components/builder/canvas-overlay-geometry.js +80 -0
- package/dist/components/builder/canvas-overlay-geometry.js.map +1 -0
- package/dist/components/builder/canvas-rect-tracking.d.ts +124 -0
- package/dist/components/builder/canvas-rect-tracking.js +198 -0
- package/dist/components/builder/canvas-rect-tracking.js.map +1 -0
- package/dist/components/builder/canvas.js +61 -12
- package/dist/components/builder/canvas.js.map +1 -1
- package/dist/components/builder/composition-field-editor.js +22 -3
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/delete-key.d.ts +38 -0
- package/dist/components/builder/delete-key.js +46 -0
- package/dist/components/builder/delete-key.js.map +1 -0
- package/dist/components/builder/drag-session.d.ts +8 -0
- package/dist/components/builder/drag-session.js +15 -0
- package/dist/components/builder/drag-session.js.map +1 -1
- package/dist/components/builder/insert-affordance-context.d.ts +19 -0
- package/dist/components/builder/insert-affordance-context.js +7 -0
- package/dist/components/builder/insert-affordance-context.js.map +1 -0
- package/dist/components/builder/insert-context.d.ts +40 -0
- package/dist/components/builder/insert-context.js +97 -5
- package/dist/components/builder/insert-context.js.map +1 -1
- package/dist/components/builder/node-title.d.ts +60 -0
- package/dist/components/builder/node-title.js +135 -0
- package/dist/components/builder/node-title.js.map +1 -0
- package/dist/components/builder/page-tree.js +25 -5
- package/dist/components/builder/page-tree.js.map +1 -1
- package/dist/components/builder/prop-selection.d.ts +52 -0
- package/dist/components/builder/prop-selection.js +38 -0
- package/dist/components/builder/prop-selection.js.map +1 -0
- package/dist/components/builder/prop-sync-context.d.ts +49 -0
- package/dist/components/builder/prop-sync-context.js +23 -0
- package/dist/components/builder/prop-sync-context.js.map +1 -0
- package/dist/components/builder/property-inspector.js +24 -1
- package/dist/components/builder/property-inspector.js.map +1 -1
- package/dist/components/builder/reuse-context.js +1 -1
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/builder/selection-overlay.d.ts +79 -0
- package/dist/components/builder/selection-overlay.js +244 -0
- package/dist/components/builder/selection-overlay.js.map +1 -0
- package/dist/components/builder/spacing-handles.d.ts +57 -1
- package/dist/components/builder/spacing-handles.js +142 -26
- package/dist/components/builder/spacing-handles.js.map +1 -1
- package/dist/components/cms/asset-field.js +21 -5
- package/dist/components/cms/asset-field.js.map +1 -1
- package/dist/components/cms/asset-picker.js +11 -5
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/bulk-actions.d.ts +22 -3
- package/dist/components/cms/bulk-actions.js +27 -3
- package/dist/components/cms/bulk-actions.js.map +1 -1
- package/dist/components/cms/cms-store.js +19 -0
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/detail-editor.js +18 -19
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/prop-fields.js +23 -16
- package/dist/components/cms/prop-fields.js.map +1 -1
- package/dist/components/cms/publish-clean.d.ts +21 -0
- package/dist/components/cms/publish-clean.js +30 -0
- package/dist/components/cms/publish-clean.js.map +1 -0
- package/dist/components/cms/rich-text/block-style.d.ts +48 -0
- package/dist/components/cms/rich-text/block-style.js +30 -0
- package/dist/components/cms/rich-text/block-style.js.map +1 -0
- package/dist/components/cms/rich-text/embed-picker.js +4 -3
- package/dist/components/cms/rich-text/embed-picker.js.map +1 -1
- package/dist/components/cms/rich-text/extensions.d.ts +2 -0
- package/dist/components/cms/rich-text/extensions.js +4 -0
- package/dist/components/cms/rich-text/extensions.js.map +1 -1
- package/dist/components/cms/rich-text/font-size-control.d.ts +31 -0
- package/dist/components/cms/rich-text/font-size-control.js +40 -0
- package/dist/components/cms/rich-text/font-size-control.js.map +1 -0
- package/dist/components/cms/rich-text/font-size.d.ts +16 -0
- package/dist/components/cms/rich-text/font-size.js +40 -0
- package/dist/components/cms/rich-text/font-size.js.map +1 -0
- package/dist/components/cms/rich-text/toolbar-state.d.ts +3 -0
- package/dist/components/cms/rich-text/toolbar-state.js +3 -0
- package/dist/components/cms/rich-text/toolbar-state.js.map +1 -1
- package/dist/components/cms/rich-text/toolbar.d.ts +13 -0
- package/dist/components/cms/rich-text/toolbar.js +17 -5
- package/dist/components/cms/rich-text/toolbar.js.map +1 -1
- package/dist/components/cms/use-live-url.d.ts +24 -1
- package/dist/components/cms/use-live-url.js +26 -4
- package/dist/components/cms/use-live-url.js.map +1 -1
- package/dist/components/cms/use-publish-controller.js +8 -6
- package/dist/components/cms/use-publish-controller.js.map +1 -1
- package/dist/components/collection-view/cms-collection-view-source.js +15 -7
- package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
- package/dist/components/components/component-detail.js +3 -3
- package/dist/components/components/component-detail.js.map +1 -1
- package/dist/components/components/component-preview.js +1 -1
- package/dist/components/components/component-preview.js.map +1 -1
- package/dist/components/components/registry-browser.js +2 -2
- package/dist/components/components/registry-browser.js.map +1 -1
- package/dist/components/components/taxonomy.js +3 -2
- package/dist/components/components/taxonomy.js.map +1 -1
- package/dist/components/components/transfer-dialog.js +1 -1
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/lib/assets/backfill-thumbs.d.ts +67 -0
- package/dist/lib/assets/backfill-thumbs.js +120 -0
- package/dist/lib/assets/backfill-thumbs.js.map +1 -0
- package/dist/lib/assets/delete-asset.d.ts +2 -1
- package/dist/lib/assets/delete-asset.js +12 -0
- package/dist/lib/assets/delete-asset.js.map +1 -1
- package/dist/lib/assets/index.d.ts +2 -0
- package/dist/lib/assets/index.js +2 -0
- package/dist/lib/assets/index.js.map +1 -1
- package/dist/lib/assets/thumbnail.d.ts +145 -0
- package/dist/lib/assets/thumbnail.js +220 -0
- package/dist/lib/assets/thumbnail.js.map +1 -0
- package/dist/lib/assets/upload.d.ts +9 -0
- package/dist/lib/assets/upload.js +31 -0
- package/dist/lib/assets/upload.js.map +1 -1
- package/dist/lib/auth/auth-adapter.d.ts +22 -0
- package/dist/lib/auth/auth-adapter.js.map +1 -1
- package/dist/lib/auth/convex-auth-adapter.d.ts +42 -1
- package/dist/lib/auth/convex-auth-adapter.js +69 -0
- package/dist/lib/auth/convex-auth-adapter.js.map +1 -1
- package/dist/lib/auth/dev-auto-login-adapter.d.ts +2 -1
- package/dist/lib/auth/dev-auto-login-adapter.js +6 -0
- package/dist/lib/auth/dev-auto-login-adapter.js.map +1 -1
- package/dist/lib/auth/index.d.ts +1 -1
- package/dist/lib/auth/index.js.map +1 -1
- package/dist/lib/auth/local-auth-adapter.d.ts +2 -1
- package/dist/lib/auth/local-auth-adapter.js +7 -0
- package/dist/lib/auth/local-auth-adapter.js.map +1 -1
- package/dist/lib/auth-errors.d.ts +45 -3
- package/dist/lib/auth-errors.js +46 -5
- package/dist/lib/auth-errors.js.map +1 -1
- package/dist/lib/clipboard/paste-target.js +5 -2
- package/dist/lib/clipboard/paste-target.js.map +1 -1
- package/dist/lib/dnd/drop-rules.d.ts +13 -0
- package/dist/lib/dnd/drop-rules.js +15 -0
- package/dist/lib/dnd/drop-rules.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +19 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +16 -0
- package/dist/lib/i18n/messages.en.js +24 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +4 -0
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persistence/index.js +6 -3
- package/dist/lib/persistence/index.js.map +1 -1
- package/dist/lib/persona/labels.d.ts +9 -5
- package/dist/lib/persona/labels.js +13 -8
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/lib/references/references-store.js +7 -1
- package/dist/lib/references/references-store.js.map +1 -1
- package/dist/lib/session-expiry.d.ts +19 -6
- package/dist/lib/session-expiry.js +13 -1
- package/dist/lib/session-expiry.js.map +1 -1
- package/dist/lib/session-revalidation.d.ts +81 -0
- package/dist/lib/session-revalidation.js +94 -0
- package/dist/lib/session-revalidation.js.map +1 -0
- package/package.json +2 -2
- package/src/styles/studio.css +13 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { ASSET_LQIP_MAX_LENGTH } from '@elytracms/core/persistence';
|
|
2
|
+
/**
|
|
3
|
+
* Client-side preview generation (EC-400 thumb + EC-420 LQIP). Every studio image
|
|
4
|
+
* surface used to stream the full-size Convex serve URL in a plain `<img>`, so a
|
|
5
|
+
* page of big originals cost heavy egress. At the moment an upload already has the
|
|
6
|
+
* bytes in hand — and again from the assets rail's "Generate previews" backfill —
|
|
7
|
+
* ONE `createImageBitmap` decode yields BOTH variants:
|
|
8
|
+
*
|
|
9
|
+
* - **thumb** (EC-400): a ≤480px-long-edge re-encode stored as a SECOND blob;
|
|
10
|
+
* editorial-only (studio grids/pickers/table cells render it instead of the
|
|
11
|
+
* original). NEVER leaves the studio.
|
|
12
|
+
* - **lqip** (EC-420): a ≤24px, low-quality re-encode base64'd into a tiny `data:`
|
|
13
|
+
* URI (hard-capped at {@link ASSET_LQIP_MAX_LENGTH} chars) and stored INLINE on
|
|
14
|
+
* the record. Unlike the thumb it rides the delivery wire (AD-2): the
|
|
15
|
+
* `@elytracms/next` image seam feeds it to `next/image` as `placeholder="blur"`.
|
|
16
|
+
*
|
|
17
|
+
* The decisions (`planThumb` / `planLqip`) are pure, node-testable cores; the
|
|
18
|
+
* canvas re-encode (`canvasVariantEncoder`) is a thin, injectable DOM shell that
|
|
19
|
+
* decodes once and draws each requested target; the orchestrator
|
|
20
|
+
* (`generateVariants`) plans → encodes (one decode) → stores the thumb blob +
|
|
21
|
+
* builds the lqip data URI, and is testable in node with a fake encoder over an
|
|
22
|
+
* in-memory blob store.
|
|
23
|
+
*
|
|
24
|
+
* Ratified spec: thumb ≤480px, quality ~0.8 (EC-400 AD-2). lqip ≤24px, quality
|
|
25
|
+
* ~0.45 (EC-420 AD-1); UNLIKE the thumb it is generated even for images already
|
|
26
|
+
* ≤480px (any raster benefits from blur-up). Both prefer `image/webp` with an
|
|
27
|
+
* `image/jpeg` fallback (the ACTUAL returned blob type is recorded — Safari has
|
|
28
|
+
* historically ignored the requested type). Skips are silent and valid — not
|
|
29
|
+
* validation issues; a generation failure never fails the upload.
|
|
30
|
+
*/
|
|
31
|
+
/** The ratified maximum thumbnail long-edge, in pixels (EC-400 AD-2). */
|
|
32
|
+
export const THUMB_MAX_EDGE = 480;
|
|
33
|
+
/** The ratified re-encode quality for the lossy thumbnail codecs (EC-400 AD-2). */
|
|
34
|
+
export const THUMB_QUALITY = 0.8;
|
|
35
|
+
/** The ratified maximum LQIP long-edge, in pixels (EC-420 AD-1). */
|
|
36
|
+
export const LQIP_MAX_EDGE = 24;
|
|
37
|
+
/** The ratified re-encode quality for the LQIP placeholder (EC-420 AD-1). */
|
|
38
|
+
export const LQIP_QUALITY = 0.45;
|
|
39
|
+
/** Fit `width`×`height` so its long edge is ≤ `maxEdge` (never upscaling); short edge ≥ 1px. */
|
|
40
|
+
function fitLongEdge(width, height, maxEdge) {
|
|
41
|
+
const scale = Math.min(1, maxEdge / Math.max(width, height));
|
|
42
|
+
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Decide whether an asset needs a downscaled thumb, and to what size (EC-400
|
|
46
|
+
* AD-2/AD-3). Pure and deterministic — the node-tested core of the skip rules.
|
|
47
|
+
* A returned `encode` plan preserves aspect ratio, fitting the long edge to
|
|
48
|
+
* {@link THUMB_MAX_EDGE}; the short edge rounds to at least one pixel.
|
|
49
|
+
*/
|
|
50
|
+
export function planThumb(source) {
|
|
51
|
+
if (!source.contentType.startsWith('image/'))
|
|
52
|
+
return { kind: 'skip', reason: 'non-image' };
|
|
53
|
+
// SVG is vector — rasterizing loses fidelity and saves nothing (AD-3).
|
|
54
|
+
if (source.contentType === 'image/svg+xml')
|
|
55
|
+
return { kind: 'skip', reason: 'vector' };
|
|
56
|
+
const { width, height } = source;
|
|
57
|
+
if (!width || !height || width <= 0 || height <= 0)
|
|
58
|
+
return { kind: 'skip', reason: 'no-dimensions' };
|
|
59
|
+
// Already small enough — a variant would be a no-op; consumers just use `url`.
|
|
60
|
+
if (Math.max(width, height) <= THUMB_MAX_EDGE)
|
|
61
|
+
return { kind: 'skip', reason: 'already-small' };
|
|
62
|
+
return { kind: 'encode', ...fitLongEdge(width, height, THUMB_MAX_EDGE) };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Decide whether an asset needs an LQIP blur placeholder, and to what size (EC-420
|
|
66
|
+
* AD-1/AD-3). Pure and deterministic. Mirrors {@link planThumb}'s skips (non-image,
|
|
67
|
+
* SVG, no dimensions) but — UNLIKE the thumb — has NO `already-small` skip: any
|
|
68
|
+
* raster benefits from delivery blur-up, so even a ≤480px image gets one (fit to
|
|
69
|
+
* {@link LQIP_MAX_EDGE}, never upscaled).
|
|
70
|
+
*/
|
|
71
|
+
export function planLqip(source) {
|
|
72
|
+
if (!source.contentType.startsWith('image/'))
|
|
73
|
+
return { kind: 'skip', reason: 'non-image' };
|
|
74
|
+
if (source.contentType === 'image/svg+xml')
|
|
75
|
+
return { kind: 'skip', reason: 'vector' };
|
|
76
|
+
const { width, height } = source;
|
|
77
|
+
if (!width || !height || width <= 0 || height <= 0)
|
|
78
|
+
return { kind: 'skip', reason: 'no-dimensions' };
|
|
79
|
+
return { kind: 'encode', ...fitLongEdge(width, height, LQIP_MAX_EDGE) };
|
|
80
|
+
}
|
|
81
|
+
/** Promisify the callback-style `HTMLCanvasElement.toBlob`. */
|
|
82
|
+
function canvasToBlob(canvas, type, quality) {
|
|
83
|
+
return new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), type, quality));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Draw an already-decoded bitmap into a target-sized 2D canvas and encode it.
|
|
87
|
+
* Prefers `image/webp`; when the browser silently substitutes another type
|
|
88
|
+
* (Safari), retries `image/jpeg` and records whatever type actually came back.
|
|
89
|
+
* Resolves `undefined` on any failure (a valid skip, never a throw).
|
|
90
|
+
*/
|
|
91
|
+
async function encodeFromBitmap(bitmap, target) {
|
|
92
|
+
const canvas = document.createElement('canvas');
|
|
93
|
+
canvas.width = target.width;
|
|
94
|
+
canvas.height = target.height;
|
|
95
|
+
const ctx = canvas.getContext('2d');
|
|
96
|
+
if (!ctx)
|
|
97
|
+
return undefined;
|
|
98
|
+
ctx.drawImage(bitmap, 0, 0, target.width, target.height);
|
|
99
|
+
let blob = await canvasToBlob(canvas, 'image/webp', target.quality);
|
|
100
|
+
if (!blob || blob.type !== 'image/webp') {
|
|
101
|
+
blob = await canvasToBlob(canvas, 'image/jpeg', target.quality);
|
|
102
|
+
}
|
|
103
|
+
if (!blob)
|
|
104
|
+
return undefined;
|
|
105
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
106
|
+
// Zero-length re-encodes are worthless; discard them.
|
|
107
|
+
if (bytes.byteLength === 0)
|
|
108
|
+
return undefined;
|
|
109
|
+
return {
|
|
110
|
+
bytes,
|
|
111
|
+
// Record the ACTUAL type — never assume the requested one landed (AD-2).
|
|
112
|
+
contentType: blob.type || 'image/jpeg',
|
|
113
|
+
width: target.width,
|
|
114
|
+
height: target.height,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The default browser encoder: ONE `createImageBitmap` decode → per-variant 2D
|
|
119
|
+
* canvas → `toBlob` (EC-420: the same decode yields thumb AND lqip). A thin DOM
|
|
120
|
+
* shell, deliberately untested in node (the orchestrator is tested with a fake
|
|
121
|
+
* encoder). Any failure resolves an empty object, so a preview never breaks an
|
|
122
|
+
* upload.
|
|
123
|
+
*/
|
|
124
|
+
export const canvasVariantEncoder = async (source, targets) => {
|
|
125
|
+
const out = {};
|
|
126
|
+
try {
|
|
127
|
+
if (typeof createImageBitmap !== 'function' || typeof document === 'undefined')
|
|
128
|
+
return out;
|
|
129
|
+
const bitmap = await createImageBitmap(source);
|
|
130
|
+
try {
|
|
131
|
+
if (targets.thumb) {
|
|
132
|
+
const encoded = await encodeFromBitmap(bitmap, targets.thumb);
|
|
133
|
+
if (encoded)
|
|
134
|
+
out.thumb = encoded;
|
|
135
|
+
}
|
|
136
|
+
if (targets.lqip) {
|
|
137
|
+
const encoded = await encodeFromBitmap(bitmap, targets.lqip);
|
|
138
|
+
if (encoded)
|
|
139
|
+
out.lqip = encoded;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
bitmap.close?.();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Undecodable — a silent, valid skip, never a thrown failure.
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
};
|
|
152
|
+
/** Base64-encode bytes for a `data:` URI. `undefined` when no encoder is available. */
|
|
153
|
+
function bytesToBase64(bytes) {
|
|
154
|
+
// `btoa` exists in browsers and Node ≥16; absence (exotic runtime) → no lqip.
|
|
155
|
+
if (typeof btoa !== 'function')
|
|
156
|
+
return undefined;
|
|
157
|
+
let binary = '';
|
|
158
|
+
const chunk = 0x8000;
|
|
159
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
160
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
161
|
+
}
|
|
162
|
+
return btoa(binary);
|
|
163
|
+
}
|
|
164
|
+
/** Build a `data:<type>;base64,<payload>` URI from encoded bytes; `undefined` on failure. */
|
|
165
|
+
function toDataUri(encoded) {
|
|
166
|
+
const b64 = bytesToBase64(encoded.bytes);
|
|
167
|
+
return b64 ? `data:${encoded.contentType};base64,${b64}` : undefined;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Plan → encode (ONE decode) → store/inline BOTH variants (EC-400 + EC-420). The
|
|
171
|
+
* thumb rides a SECOND `store()` call (the same blob boundary the upload uses); the
|
|
172
|
+
* lqip is inlined as a capped base64 `data:` URI needing no blob store at all.
|
|
173
|
+
* `want` narrows which variants to produce — the per-variant backfill (AD-4) asks
|
|
174
|
+
* for only the missing one so an existing thumb/lqip is never regenerated. Returns
|
|
175
|
+
* whichever variants succeeded; any skip/failure simply omits that variant (never
|
|
176
|
+
* throws — a preview must never fail an upload).
|
|
177
|
+
*/
|
|
178
|
+
export async function generateVariants(deps, source, meta, want = { thumb: true, lqip: true }) {
|
|
179
|
+
const thumbPlan = want.thumb === false ? { kind: 'skip', reason: 'already-small' } : planThumb(meta);
|
|
180
|
+
const lqipPlan = want.lqip === false ? { kind: 'skip', reason: 'no-dimensions' } : planLqip(meta);
|
|
181
|
+
const targets = {};
|
|
182
|
+
if (thumbPlan.kind === 'encode') {
|
|
183
|
+
targets.thumb = { width: thumbPlan.width, height: thumbPlan.height, quality: THUMB_QUALITY };
|
|
184
|
+
}
|
|
185
|
+
if (lqipPlan.kind === 'encode') {
|
|
186
|
+
targets.lqip = { width: lqipPlan.width, height: lqipPlan.height, quality: LQIP_QUALITY };
|
|
187
|
+
}
|
|
188
|
+
if (!targets.thumb && !targets.lqip)
|
|
189
|
+
return {};
|
|
190
|
+
const encode = deps.encode ?? canvasVariantEncoder;
|
|
191
|
+
let encoded;
|
|
192
|
+
try {
|
|
193
|
+
encoded = await encode(source, targets);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
const result = {};
|
|
199
|
+
// Thumb: a SECOND stored blob (needs a key-minting backend; a put-only backend
|
|
200
|
+
// simply gets no thumb — the lqip still ships).
|
|
201
|
+
if (encoded.thumb && deps.blobs.store) {
|
|
202
|
+
try {
|
|
203
|
+
const storageKey = await deps.blobs.store(encoded.thumb.bytes, encoded.thumb.contentType);
|
|
204
|
+
const url = (await deps.blobs.resolveUrl?.(storageKey)) ?? deps.blobs.url(storageKey);
|
|
205
|
+
result.thumb = { storageKey, url, width: encoded.thumb.width, height: encoded.thumb.height };
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// No thumb — never fail the run.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Lqip: inline data URI, no blob. Enforce the hard cap; over-cap → skip (AD-1)
|
|
212
|
+
// rather than silently persist an oversized value the schema would reject anyway.
|
|
213
|
+
if (encoded.lqip) {
|
|
214
|
+
const dataUri = toDataUri(encoded.lqip);
|
|
215
|
+
if (dataUri && dataUri.length <= ASSET_LQIP_MAX_LENGTH)
|
|
216
|
+
result.lqip = dataUri;
|
|
217
|
+
}
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
//# sourceMappingURL=thumbnail.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"thumbnail.js","sourceRoot":"","sources":["../../../src/lib/assets/thumbnail.ts"],"names":[],"mappings":"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","sourcesContent":["import { ASSET_LQIP_MAX_LENGTH } from '@elytracms/core/persistence'\nimport type { AssetThumbVariant, BlobStorageAdapter } from '@elytracms/core/persistence'\n\n/**\n * Client-side preview generation (EC-400 thumb + EC-420 LQIP). Every studio image\n * surface used to stream the full-size Convex serve URL in a plain `<img>`, so a\n * page of big originals cost heavy egress. At the moment an upload already has the\n * bytes in hand — and again from the assets rail's \"Generate previews\" backfill —\n * ONE `createImageBitmap` decode yields BOTH variants:\n *\n * - **thumb** (EC-400): a ≤480px-long-edge re-encode stored as a SECOND blob;\n * editorial-only (studio grids/pickers/table cells render it instead of the\n * original). NEVER leaves the studio.\n * - **lqip** (EC-420): a ≤24px, low-quality re-encode base64'd into a tiny `data:`\n * URI (hard-capped at {@link ASSET_LQIP_MAX_LENGTH} chars) and stored INLINE on\n * the record. Unlike the thumb it rides the delivery wire (AD-2): the\n * `@elytracms/next` image seam feeds it to `next/image` as `placeholder=\"blur\"`.\n *\n * The decisions (`planThumb` / `planLqip`) are pure, node-testable cores; the\n * canvas re-encode (`canvasVariantEncoder`) is a thin, injectable DOM shell that\n * decodes once and draws each requested target; the orchestrator\n * (`generateVariants`) plans → encodes (one decode) → stores the thumb blob +\n * builds the lqip data URI, and is testable in node with a fake encoder over an\n * in-memory blob store.\n *\n * Ratified spec: thumb ≤480px, quality ~0.8 (EC-400 AD-2). lqip ≤24px, quality\n * ~0.45 (EC-420 AD-1); UNLIKE the thumb it is generated even for images already\n * ≤480px (any raster benefits from blur-up). Both prefer `image/webp` with an\n * `image/jpeg` fallback (the ACTUAL returned blob type is recorded — Safari has\n * historically ignored the requested type). Skips are silent and valid — not\n * validation issues; a generation failure never fails the upload.\n */\n\n/** The ratified maximum thumbnail long-edge, in pixels (EC-400 AD-2). */\nexport const THUMB_MAX_EDGE = 480\n\n/** The ratified re-encode quality for the lossy thumbnail codecs (EC-400 AD-2). */\nexport const THUMB_QUALITY = 0.8\n\n/** The ratified maximum LQIP long-edge, in pixels (EC-420 AD-1). */\nexport const LQIP_MAX_EDGE = 24\n\n/** The ratified re-encode quality for the LQIP placeholder (EC-420 AD-1). */\nexport const LQIP_QUALITY = 0.45\n\n/** The re-encode target size a variant needs, or the reason it is skipped. */\nexport type ThumbPlan =\n | { kind: 'skip'; reason: 'non-image' | 'vector' | 'no-dimensions' | 'already-small' }\n | { kind: 'encode'; width: number; height: number }\n\n/** The LQIP plan — like {@link ThumbPlan} but WITHOUT the `already-small` skip (AD-3). */\nexport type LqipPlan =\n | { kind: 'skip'; reason: 'non-image' | 'vector' | 'no-dimensions' }\n | { kind: 'encode'; width: number; height: number }\n\n/** The metadata `planThumb` / `planLqip` and the encoders reason over. */\nexport interface ThumbSource {\n contentType: string\n width?: number\n height?: number\n}\n\n/** Fit `width`×`height` so its long edge is ≤ `maxEdge` (never upscaling); short edge ≥ 1px. */\nfunction fitLongEdge(width: number, height: number, maxEdge: number): { width: number; height: number } {\n const scale = Math.min(1, maxEdge / Math.max(width, height))\n return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) }\n}\n\n/**\n * Decide whether an asset needs a downscaled thumb, and to what size (EC-400\n * AD-2/AD-3). Pure and deterministic — the node-tested core of the skip rules.\n * A returned `encode` plan preserves aspect ratio, fitting the long edge to\n * {@link THUMB_MAX_EDGE}; the short edge rounds to at least one pixel.\n */\nexport function planThumb(source: ThumbSource): ThumbPlan {\n if (!source.contentType.startsWith('image/')) return { kind: 'skip', reason: 'non-image' }\n // SVG is vector — rasterizing loses fidelity and saves nothing (AD-3).\n if (source.contentType === 'image/svg+xml') return { kind: 'skip', reason: 'vector' }\n const { width, height } = source\n if (!width || !height || width <= 0 || height <= 0) return { kind: 'skip', reason: 'no-dimensions' }\n // Already small enough — a variant would be a no-op; consumers just use `url`.\n if (Math.max(width, height) <= THUMB_MAX_EDGE) return { kind: 'skip', reason: 'already-small' }\n return { kind: 'encode', ...fitLongEdge(width, height, THUMB_MAX_EDGE) }\n}\n\n/**\n * Decide whether an asset needs an LQIP blur placeholder, and to what size (EC-420\n * AD-1/AD-3). Pure and deterministic. Mirrors {@link planThumb}'s skips (non-image,\n * SVG, no dimensions) but — UNLIKE the thumb — has NO `already-small` skip: any\n * raster benefits from delivery blur-up, so even a ≤480px image gets one (fit to\n * {@link LQIP_MAX_EDGE}, never upscaled).\n */\nexport function planLqip(source: ThumbSource): LqipPlan {\n if (!source.contentType.startsWith('image/')) return { kind: 'skip', reason: 'non-image' }\n if (source.contentType === 'image/svg+xml') return { kind: 'skip', reason: 'vector' }\n const { width, height } = source\n if (!width || !height || width <= 0 || height <= 0) return { kind: 'skip', reason: 'no-dimensions' }\n return { kind: 'encode', ...fitLongEdge(width, height, LQIP_MAX_EDGE) }\n}\n\n/** The bytes + ACTUAL mime type + dimensions an encoder produced for one variant. */\nexport interface EncodedVariant {\n bytes: Uint8Array\n /** The blob's real type — NOT necessarily the requested one (AD-2 Safari note). */\n contentType: string\n width: number\n height: number\n}\n\n/** A re-encode target the encoder draws into (per-variant quality). */\nexport interface EncodeTarget {\n width: number\n height: number\n quality: number\n}\n\n/** Which variants to draw+encode from the single decode, at their target sizes. */\nexport interface VariantTargets {\n thumb?: EncodeTarget\n lqip?: EncodeTarget\n}\n\n/** The encoded output of one decode: whichever requested variants encoded. */\nexport interface EncodedVariants {\n thumb?: EncodedVariant\n lqip?: EncodedVariant\n}\n\n/**\n * Turn source image bytes into downscaled preview bytes for BOTH variants from a\n * single decode. Returns whichever variants encoded (an empty object when the\n * source cannot be decoded/encoded) — never throws; the caller treats a missing\n * variant as \"no preview\", never a failure. Injectable so the orchestrator + the\n * upload/backfill flows unit-test in node without a browser.\n */\nexport type VariantEncoder = (source: Blob, targets: VariantTargets) => Promise<EncodedVariants>\n\n/** Promisify the callback-style `HTMLCanvasElement.toBlob`. */\nfunction canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> {\n return new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), type, quality))\n}\n\n/**\n * Draw an already-decoded bitmap into a target-sized 2D canvas and encode it.\n * Prefers `image/webp`; when the browser silently substitutes another type\n * (Safari), retries `image/jpeg` and records whatever type actually came back.\n * Resolves `undefined` on any failure (a valid skip, never a throw).\n */\nasync function encodeFromBitmap(\n bitmap: ImageBitmap,\n target: EncodeTarget,\n): Promise<EncodedVariant | undefined> {\n const canvas = document.createElement('canvas')\n canvas.width = target.width\n canvas.height = target.height\n const ctx = canvas.getContext('2d')\n if (!ctx) return undefined\n ctx.drawImage(bitmap, 0, 0, target.width, target.height)\n let blob = await canvasToBlob(canvas, 'image/webp', target.quality)\n if (!blob || blob.type !== 'image/webp') {\n blob = await canvasToBlob(canvas, 'image/jpeg', target.quality)\n }\n if (!blob) return undefined\n const bytes = new Uint8Array(await blob.arrayBuffer())\n // Zero-length re-encodes are worthless; discard them.\n if (bytes.byteLength === 0) return undefined\n return {\n bytes,\n // Record the ACTUAL type — never assume the requested one landed (AD-2).\n contentType: blob.type || 'image/jpeg',\n width: target.width,\n height: target.height,\n }\n}\n\n/**\n * The default browser encoder: ONE `createImageBitmap` decode → per-variant 2D\n * canvas → `toBlob` (EC-420: the same decode yields thumb AND lqip). A thin DOM\n * shell, deliberately untested in node (the orchestrator is tested with a fake\n * encoder). Any failure resolves an empty object, so a preview never breaks an\n * upload.\n */\nexport const canvasVariantEncoder: VariantEncoder = async (source, targets) => {\n const out: EncodedVariants = {}\n try {\n if (typeof createImageBitmap !== 'function' || typeof document === 'undefined') return out\n const bitmap = await createImageBitmap(source)\n try {\n if (targets.thumb) {\n const encoded = await encodeFromBitmap(bitmap, targets.thumb)\n if (encoded) out.thumb = encoded\n }\n if (targets.lqip) {\n const encoded = await encodeFromBitmap(bitmap, targets.lqip)\n if (encoded) out.lqip = encoded\n }\n } finally {\n bitmap.close?.()\n }\n } catch {\n // Undecodable — a silent, valid skip, never a thrown failure.\n return out\n }\n return out\n}\n\n/** Base64-encode bytes for a `data:` URI. `undefined` when no encoder is available. */\nfunction bytesToBase64(bytes: Uint8Array): string | undefined {\n // `btoa` exists in browsers and Node ≥16; absence (exotic runtime) → no lqip.\n if (typeof btoa !== 'function') return undefined\n let binary = ''\n const chunk = 0x8000\n for (let i = 0; i < bytes.length; i += chunk) {\n binary += String.fromCharCode(...bytes.subarray(i, i + chunk))\n }\n return btoa(binary)\n}\n\n/** Build a `data:<type>;base64,<payload>` URI from encoded bytes; `undefined` on failure. */\nfunction toDataUri(encoded: EncodedVariant): string | undefined {\n const b64 = bytesToBase64(encoded.bytes)\n return b64 ? `data:${encoded.contentType};base64,${b64}` : undefined\n}\n\n/** Blob storage + the (optionally injected) encoder the orchestrator needs. */\nexport interface VariantGenerationDeps {\n blobs: BlobStorageAdapter\n /** Defaults to {@link canvasVariantEncoder}; tests inject a deterministic fake. */\n encode?: VariantEncoder\n}\n\n/** Which variants the orchestrator should PRODUCE (backfill fills only the missing ones). */\nexport interface VariantWant {\n thumb?: boolean\n lqip?: boolean\n}\n\n/** The stored/inlined variants a generation run produced (either may be absent). */\nexport interface GeneratedVariants {\n /** The stored downscaled thumb (a second blob's key/url + dims), or absent. */\n thumb?: AssetThumbVariant\n /** The LQIP blur-placeholder `data:` URI (≤ cap), or absent. */\n lqip?: string\n}\n\n/**\n * Plan → encode (ONE decode) → store/inline BOTH variants (EC-400 + EC-420). The\n * thumb rides a SECOND `store()` call (the same blob boundary the upload uses); the\n * lqip is inlined as a capped base64 `data:` URI needing no blob store at all.\n * `want` narrows which variants to produce — the per-variant backfill (AD-4) asks\n * for only the missing one so an existing thumb/lqip is never regenerated. Returns\n * whichever variants succeeded; any skip/failure simply omits that variant (never\n * throws — a preview must never fail an upload).\n */\nexport async function generateVariants(\n deps: VariantGenerationDeps,\n source: Blob,\n meta: ThumbSource,\n want: VariantWant = { thumb: true, lqip: true },\n): Promise<GeneratedVariants> {\n const thumbPlan: ThumbPlan = want.thumb === false ? { kind: 'skip', reason: 'already-small' } : planThumb(meta)\n const lqipPlan: LqipPlan = want.lqip === false ? { kind: 'skip', reason: 'no-dimensions' } : planLqip(meta)\n\n const targets: VariantTargets = {}\n if (thumbPlan.kind === 'encode') {\n targets.thumb = { width: thumbPlan.width, height: thumbPlan.height, quality: THUMB_QUALITY }\n }\n if (lqipPlan.kind === 'encode') {\n targets.lqip = { width: lqipPlan.width, height: lqipPlan.height, quality: LQIP_QUALITY }\n }\n if (!targets.thumb && !targets.lqip) return {}\n\n const encode = deps.encode ?? canvasVariantEncoder\n let encoded: EncodedVariants\n try {\n encoded = await encode(source, targets)\n } catch {\n return {}\n }\n\n const result: GeneratedVariants = {}\n // Thumb: a SECOND stored blob (needs a key-minting backend; a put-only backend\n // simply gets no thumb — the lqip still ships).\n if (encoded.thumb && deps.blobs.store) {\n try {\n const storageKey = await deps.blobs.store(encoded.thumb.bytes, encoded.thumb.contentType)\n const url = (await deps.blobs.resolveUrl?.(storageKey)) ?? deps.blobs.url(storageKey)\n result.thumb = { storageKey, url, width: encoded.thumb.width, height: encoded.thumb.height }\n } catch {\n // No thumb — never fail the run.\n }\n }\n // Lqip: inline data URI, no blob. Enforce the hard cap; over-cap → skip (AD-1)\n // rather than silently persist an oversized value the schema would reject anyway.\n if (encoded.lqip) {\n const dataUri = toDataUri(encoded.lqip)\n if (dataUri && dataUri.length <= ASSET_LQIP_MAX_LENGTH) result.lqip = dataUri\n }\n return result\n}\n"]}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AssetRecord, AssetRepository, BlobStorageAdapter } from '@elytracms/core/persistence';
|
|
2
2
|
import type { ImageDimensions } from './image-dimensions.js';
|
|
3
|
+
import type { GeneratedVariants } from './thumbnail.js';
|
|
3
4
|
import type { UploadFailure, UploadState } from './upload-state.js';
|
|
4
5
|
/**
|
|
5
6
|
* Upload orchestration (EC-151): request upload URL → transfer bytes →
|
|
@@ -31,6 +32,14 @@ export interface UploadDeps {
|
|
|
31
32
|
transport?: UploadTransport;
|
|
32
33
|
/** Injectable dimension detection; defaults to `createImageBitmap`-based. */
|
|
33
34
|
detectDimensions?: (file: UploadableFile) => Promise<ImageDimensions | undefined>;
|
|
35
|
+
/**
|
|
36
|
+
* Injectable preview generation (EC-400 thumb + EC-420 lqip). Defaults to a
|
|
37
|
+
* single `createImageBitmap` + canvas decode that stores the thumb via a second
|
|
38
|
+
* `blobs.store()` and inlines the lqip data URI. Returns `{}` (or a partial) for
|
|
39
|
+
* skipped/failed variants — a preview NEVER fails the upload (AD-2). Tests inject
|
|
40
|
+
* a deterministic implementation.
|
|
41
|
+
*/
|
|
42
|
+
generateVariants?: (file: UploadableFile, dimensions: ImageDimensions | undefined) => Promise<GeneratedVariants>;
|
|
34
43
|
}
|
|
35
44
|
export interface UploadRequest {
|
|
36
45
|
projectId: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { detectImageDimensions } from './image-dimensions.js';
|
|
2
|
+
import { generateVariants } from './thumbnail.js';
|
|
2
3
|
import { initialUploadState, uploadReducer } from './upload-state.js';
|
|
3
4
|
/**
|
|
4
5
|
* Default browser transport: XMLHttpRequest POST for real byte progress
|
|
@@ -40,6 +41,23 @@ async function defaultDetectDimensions(file) {
|
|
|
40
41
|
return undefined;
|
|
41
42
|
return detectImageDimensions(file);
|
|
42
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Default preview step (EC-400 thumb + EC-420 lqip): decode the real `File` (which
|
|
46
|
+
* IS a `Blob` in the browser) ONCE and produce both variants — store the thumb on
|
|
47
|
+
* `blobs`, inline the lqip data URI. Guards the `Blob` narrowing so a non-browser
|
|
48
|
+
* `UploadableFile` (or a backend without `store()`) simply yields no thumb — never
|
|
49
|
+
* an error that could fail the upload.
|
|
50
|
+
*/
|
|
51
|
+
function defaultGenerateVariants(blobs) {
|
|
52
|
+
return async (file, dimensions) => {
|
|
53
|
+
if (typeof Blob === 'undefined' || !(file instanceof Blob))
|
|
54
|
+
return {};
|
|
55
|
+
return generateVariants({ blobs }, file, {
|
|
56
|
+
contentType: file.type,
|
|
57
|
+
...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
}
|
|
43
61
|
function failureOf(stage, error) {
|
|
44
62
|
return {
|
|
45
63
|
stage,
|
|
@@ -104,6 +122,17 @@ export async function runUpload(deps, request, previous) {
|
|
|
104
122
|
try {
|
|
105
123
|
const detect = deps.detectDimensions ?? defaultDetectDimensions;
|
|
106
124
|
const dimensions = await detect(file);
|
|
125
|
+
// EC-400 + EC-420: produce the thumb + lqip previews from the bytes already in
|
|
126
|
+
// hand (one decode). A preview failure is swallowed — the asset simply ships
|
|
127
|
+
// without them (AD-2, scope §4); absence = today's behaviour, never an error.
|
|
128
|
+
const generate = deps.generateVariants ?? defaultGenerateVariants(blobs);
|
|
129
|
+
let variants;
|
|
130
|
+
try {
|
|
131
|
+
variants = await generate(file, dimensions);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
variants = {};
|
|
135
|
+
}
|
|
107
136
|
const url = (await blobs.resolveUrl?.(storageKey)) ?? blobs.url(storageKey);
|
|
108
137
|
const asset = await assets.upsertAsset(projectId, {
|
|
109
138
|
filename: file.name,
|
|
@@ -112,6 +141,8 @@ export async function runUpload(deps, request, previous) {
|
|
|
112
141
|
storageKey,
|
|
113
142
|
url,
|
|
114
143
|
...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
|
|
144
|
+
...(variants.thumb ? { thumb: variants.thumb } : {}),
|
|
145
|
+
...(variants.lqip ? { lqip: variants.lqip } : {}),
|
|
115
146
|
...(request.alt !== undefined ? { alt: request.alt } : {}),
|
|
116
147
|
});
|
|
117
148
|
dispatch({ type: 'confirmed', assetId: asset.id });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upload.js","sourceRoot":"","sources":["../../../src/lib/assets/upload.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAE1D,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AA0BlE;;;GAGG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAoB,CAAC,SAAS,EAAE,IAAI,EAAE,UAAU,EAAE,EAAE,CACjF,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;IAC9B,MAAM,GAAG,GAAG,IAAI,cAAc,EAAE,CAAA;IAChC,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;IAC3B,IAAI,IAAI,CAAC,IAAI;QAAE,GAAG,CAAC,gBAAgB,CAAC,cAAc,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;IAC9D,GAAG,CAAC,MAAM,CAAC,UAAU,GAAG,CAAC,KAAK,EAAE,EAAE;QAChC,IAAI,KAAK,CAAC,gBAAgB;YAAE,UAAU,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,KAAK,CAAC,CAAA;IACnE,CAAC,CAAA;IACD,GAAG,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAC,CAAA;IAC7E,GAAG,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAC,CAAA;IAC5D,GAAG,CAAC,MAAM,GAAG,GAAG,EAAE;QAChB,IAAI,GAAG,CAAC,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,MAAM,IAAI,GAAG,EAAE,CAAC;YAC1C,MAAM,CAAC,IAAI,KAAK,CAAC,2BAA2B,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;YAC3D,OAAM;QACR,CAAC;QACD,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,YAAY,CAA4B,CAAA;YACvE,IAAI,OAAO,OAAO,CAAC,SAAS,KAAK,QAAQ,IAAI,OAAO,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBAC1E,OAAO,CAAC,EAAE,UAAU,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC,CAAA;YAC5C,CAAC;iBAAM,CAAC;gBACN,MAAM,CAAC,IAAI,KAAK,CAAC,8CAA8C,CAAC,CAAC,CAAA;YACnE,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,CAAC,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC,CAAA;QAC1D,CAAC;IACH,CAAC,CAAA;IACD,KAAK,IAAI,CAAC,WAAW,EAAE,CAAC,IAAI,CAC1B,CAAC,KAAK,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,EAC1B,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,4BAA4B,CAAC,CAAC,CACtD,CAAA;AACH,CAAC,CAAC,CAAA;AAuBJ,KAAK,UAAU,uBAAuB,CAAC,IAAoB;IACzD,IAAI,OAAO,IAAI,KAAK,WAAW,IAAI,CAAC,CAAC,IAAI,YAAY,IAAI,CAAC;QAAE,OAAO,SAAS,CAAA;IAC5E,OAAO,qBAAqB,CAAC,IAA+B,CAAC,CAAA;AAC/D,CAAC;AAED,SAAS,SAAS,CAAC,KAA6B,EAAE,KAAc;IAC9D,OAAO;QACL,KAAK;QACL,OAAO,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;QAC/D,SAAS,EAAE,IAAI;KAChB,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAC7B,IAAgB,EAChB,OAAsB,EACtB,QAAsB;IAEtB,IAAI,KAAK,GAAG,QAAQ,IAAI,kBAAkB,CAAA;IAC1C,MAAM,QAAQ,GAAG,CAAC,KAAkB,EAAQ,EAAE;QAC5C,KAAK,GAAG,aAAa,CAAC,KAAK,EAAE,KAAK,CAAC,CAAA;QACnC,OAAO,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,CAAA;IAC1B,CAAC,CAAA;IACD,QAAQ,CAAC,KAAK,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAA;IAE1E,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;IAC9B,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,GAAG,OAAO,CAAA;IAEnC,6EAA6E;IAC7E,IAAI,UAAkB,CAAA;IACtB,IAAI,CAAC;QACH,IAAI,KAAK,CAAC,eAAe,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YAC5C,MAAM,SAAS,GAAG,MAAM,KAAK,CAAC,eAAe,EAAE,CAAA;YAC/C,QAAQ,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,CAAA;YAC/B,IAAI,CAAC;gBACH,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,SAAS,EAAE,IAAI,EAAE,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,CACnE,QAAQ,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAC9C,CAAA;gBACD,UAAU,GAAG,IAAI,CAAC,UAAU,CAAA;YAC9B,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,SAAS,CAAC,UAAU,EAAE,KAAK,CAAC,EAAE,CAAC,CAAA;gBAC7D,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,SAAS,CAAC,UAAU,EAAE,KAAK,CAAC,EAAE,KAAK,EAAE,CAAA;YACpE,CAAC;QACH,CAAC;aAAM,CAAC;YACN,yEAAyE;YACzE,QAAQ,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,CAAA;YAC/B,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC,CAAA;YACtD,IAAI,CAAC;gBACH,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;oBAChB,UAAU,GAAG,MAAM,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;gBAClD,CAAC;qBAAM,CAAC;oBACN,UAAU,GAAG,GAAG,SAAS,IAAI,IAAI,CAAC,IAAI,EAAE,CAAA;oBACxC,MAAM,KAAK,CAAC,GAAG,CAAC,UAAU,EAAE,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;gBAC/C,CAAC;YACH,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,SAAS,CAAC,UAAU,EAAE,KAAK,CAAC,EAAE,CAAC,CAAA;gBAC7D,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,SAAS,CAAC,UAAU,EAAE,KAAK,CAAC,EAAE,KAAK,EAAE,CAAA;YACpE,CAAC;QACH,CAAC;IACH,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,SAAS,CAAC,SAAS,EAAE,KAAK,CAAC,EAAE,CAAC,CAAA;QAC5D,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,SAAS,CAAC,SAAS,EAAE,KAAK,CAAC,EAAE,KAAK,EAAE,CAAA;IACnE,CAAC;IACD,QAAQ,CAAC,EAAE,IAAI,EAAE,aAAa,EAAE,CAAC,CAAA;IAEjC,8EAA8E;IAC9E,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,gBAAgB,IAAI,uBAAuB,CAAA;QAC/D,MAAM,UAAU,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,CAAA;QACrC,MAAM,GAAG,GAAG,CAAC,MAAM,KAAK,CAAC,UAAU,EAAE,CAAC,UAAU,CAAC,CAAC,IAAI,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,CAAA;QAC3E,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,EAAE;YAChD,QAAQ,EAAE,IAAI,CAAC,IAAI;YACnB,WAAW,EAAE,IAAI,CAAC,IAAI,IAAI,0BAA0B;YACpD,QAAQ,EAAE,IAAI,CAAC,IAAI;YACnB,UAAU;YACV,GAAG;YACH,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC7E,GAAG,CAAC,OAAO,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC3D,CAAC,CAAA;QACF,QAAQ,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAAA;QAClD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,CAAA;IACnC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,SAAS,CAAC,SAAS,EAAE,KAAK,CAAC,EAAE,CAAC,CAAA;QAC5D,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,SAAS,CAAC,SAAS,EAAE,KAAK,CAAC,EAAE,KAAK,EAAE,CAAA;IACnE,CAAC;AACH,CAAC","sourcesContent":["import type { AssetRecord, AssetRepository, BlobStorageAdapter } from '@elytracms/core/persistence'\nimport { detectImageDimensions } from './image-dimensions'\nimport type { ImageDimensions } from './image-dimensions'\nimport { initialUploadState, uploadReducer } from './upload-state'\nimport type { UploadEvent, UploadFailure, UploadState } from './upload-state'\n\n/**\n * Upload orchestration (EC-151): request upload URL → transfer bytes →\n * confirm by writing the EC-044 metadata record (dimensions, mime, serve URL).\n * Storage is reached *only* through the {@link BlobStorageAdapter}; every\n * effect is injectable so the whole flow unit-tests without a browser or a\n * live Convex deployment.\n */\n\n/** The structural slice of a browser `File` the upload flow needs. */\nexport interface UploadableFile {\n name: string\n type: string\n size: number\n arrayBuffer(): Promise<ArrayBuffer>\n}\n\n/** Sends bytes to an upload URL, reporting progress; returns the assigned storage key. */\nexport type UploadTransport = (\n uploadUrl: string,\n file: UploadableFile,\n onProgress: (loaded: number, total: number) => void,\n) => Promise<{ storageKey: string }>\n\n/**\n * Default browser transport: XMLHttpRequest POST for real byte progress\n * (fetch has no upload progress without streams support everywhere).\n */\nexport const xhrUploadTransport: UploadTransport = (uploadUrl, file, onProgress) =>\n new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.open('POST', uploadUrl)\n if (file.type) xhr.setRequestHeader('Content-Type', file.type)\n xhr.upload.onprogress = (event) => {\n if (event.lengthComputable) onProgress(event.loaded, event.total)\n }\n xhr.onerror = () => reject(new Error('Network error while uploading bytes.'))\n xhr.onabort = () => reject(new Error('Upload was aborted.'))\n xhr.onload = () => {\n if (xhr.status < 200 || xhr.status >= 300) {\n reject(new Error(`Upload failed with HTTP ${xhr.status}.`))\n return\n }\n try {\n const payload = JSON.parse(xhr.responseText) as { storageId?: unknown }\n if (typeof payload.storageId === 'string' && payload.storageId.length > 0) {\n resolve({ storageKey: payload.storageId })\n } else {\n reject(new Error('Upload response did not include a storageId.'))\n }\n } catch {\n reject(new Error('Upload response was not valid JSON.'))\n }\n }\n void file.arrayBuffer().then(\n (bytes) => xhr.send(bytes),\n () => reject(new Error('Could not read file bytes.')),\n )\n })\n\nexport interface UploadDeps {\n blobs: BlobStorageAdapter\n assets: AssetRepository\n /** Client-direct transport; only used when `blobs.createUploadUrl` exists. */\n transport?: UploadTransport\n /** Injectable dimension detection; defaults to `createImageBitmap`-based. */\n detectDimensions?: (file: UploadableFile) => Promise<ImageDimensions | undefined>\n}\n\nexport interface UploadRequest {\n projectId: string\n file: UploadableFile\n alt?: string\n /** Observe every state transition (drives progress UI). */\n onState?: (state: UploadState) => void\n}\n\nexport type UploadResult =\n | { ok: true; asset: AssetRecord; state: UploadState }\n | { ok: false; failure: UploadFailure; state: UploadState }\n\nasync function defaultDetectDimensions(file: UploadableFile): Promise<ImageDimensions | undefined> {\n if (typeof Blob === 'undefined' || !(file instanceof Blob)) return undefined\n return detectImageDimensions(file as Blob & { type: string })\n}\n\nfunction failureOf(stage: UploadFailure['stage'], error: unknown): UploadFailure {\n return {\n stage,\n message: error instanceof Error ? error.message : String(error),\n retryable: true,\n }\n}\n\n/**\n * Run one upload attempt. Resolves with a discriminated result — it never\n * throws, and a failed transfer leaves no metadata record behind. Pass the\n * previous `state` to retry (the attempt counter carries over).\n */\nexport async function runUpload(\n deps: UploadDeps,\n request: UploadRequest,\n previous?: UploadState,\n): Promise<UploadResult> {\n let state = previous ?? initialUploadState\n const dispatch = (event: UploadEvent): void => {\n state = uploadReducer(state, event)\n request.onState?.(state)\n }\n dispatch(state.phase === 'failed' ? { type: 'retry' } : { type: 'start' })\n\n const { blobs, assets } = deps\n const { projectId, file } = request\n\n // --- prepare + transfer ---------------------------------------------------\n let storageKey: string\n try {\n if (blobs.createUploadUrl && deps.transport) {\n const uploadUrl = await blobs.createUploadUrl()\n dispatch({ type: 'url-ready' })\n try {\n const sent = await deps.transport(uploadUrl, file, (loaded, total) =>\n dispatch({ type: 'progress', loaded, total }),\n )\n storageKey = sent.storageKey\n } catch (error) {\n dispatch({ type: 'failed', ...failureOf('transfer', error) })\n return { ok: false, failure: failureOf('transfer', error), state }\n }\n } else {\n // Backend without client-direct uploads: push bytes through the adapter.\n dispatch({ type: 'url-ready' })\n const bytes = new Uint8Array(await file.arrayBuffer())\n try {\n if (blobs.store) {\n storageKey = await blobs.store(bytes, file.type)\n } else {\n storageKey = `${projectId}/${file.name}`\n await blobs.put(storageKey, bytes, file.type)\n }\n } catch (error) {\n dispatch({ type: 'failed', ...failureOf('transfer', error) })\n return { ok: false, failure: failureOf('transfer', error), state }\n }\n }\n } catch (error) {\n dispatch({ type: 'failed', ...failureOf('prepare', error) })\n return { ok: false, failure: failureOf('prepare', error), state }\n }\n dispatch({ type: 'transferred' })\n\n // --- confirm: metadata record linked to the stored blob --------------------\n try {\n const detect = deps.detectDimensions ?? defaultDetectDimensions\n const dimensions = await detect(file)\n const url = (await blobs.resolveUrl?.(storageKey)) ?? blobs.url(storageKey)\n const asset = await assets.upsertAsset(projectId, {\n filename: file.name,\n contentType: file.type || 'application/octet-stream',\n byteSize: file.size,\n storageKey,\n url,\n ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),\n ...(request.alt !== undefined ? { alt: request.alt } : {}),\n })\n dispatch({ type: 'confirmed', assetId: asset.id })\n return { ok: true, asset, state }\n } catch (error) {\n dispatch({ type: 'failed', ...failureOf('confirm', error) })\n return { ok: false, failure: failureOf('confirm', error), state }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"upload.js","sourceRoot":"","sources":["../../../src/lib/assets/upload.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n AssetRecord,\n AssetRepository,\n BlobStorageAdapter,\n} from '@elytracms/core/persistence'\nimport { detectImageDimensions } from './image-dimensions'\nimport type { ImageDimensions } from './image-dimensions'\nimport { generateVariants } from './thumbnail'\nimport type { GeneratedVariants } from './thumbnail'\nimport { initialUploadState, uploadReducer } from './upload-state'\nimport type { UploadEvent, UploadFailure, UploadState } from './upload-state'\n\n/**\n * Upload orchestration (EC-151): request upload URL → transfer bytes →\n * confirm by writing the EC-044 metadata record (dimensions, mime, serve URL).\n * Storage is reached *only* through the {@link BlobStorageAdapter}; every\n * effect is injectable so the whole flow unit-tests without a browser or a\n * live Convex deployment.\n */\n\n/** The structural slice of a browser `File` the upload flow needs. */\nexport interface UploadableFile {\n name: string\n type: string\n size: number\n arrayBuffer(): Promise<ArrayBuffer>\n}\n\n/** Sends bytes to an upload URL, reporting progress; returns the assigned storage key. */\nexport type UploadTransport = (\n uploadUrl: string,\n file: UploadableFile,\n onProgress: (loaded: number, total: number) => void,\n) => Promise<{ storageKey: string }>\n\n/**\n * Default browser transport: XMLHttpRequest POST for real byte progress\n * (fetch has no upload progress without streams support everywhere).\n */\nexport const xhrUploadTransport: UploadTransport = (uploadUrl, file, onProgress) =>\n new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.open('POST', uploadUrl)\n if (file.type) xhr.setRequestHeader('Content-Type', file.type)\n xhr.upload.onprogress = (event) => {\n if (event.lengthComputable) onProgress(event.loaded, event.total)\n }\n xhr.onerror = () => reject(new Error('Network error while uploading bytes.'))\n xhr.onabort = () => reject(new Error('Upload was aborted.'))\n xhr.onload = () => {\n if (xhr.status < 200 || xhr.status >= 300) {\n reject(new Error(`Upload failed with HTTP ${xhr.status}.`))\n return\n }\n try {\n const payload = JSON.parse(xhr.responseText) as { storageId?: unknown }\n if (typeof payload.storageId === 'string' && payload.storageId.length > 0) {\n resolve({ storageKey: payload.storageId })\n } else {\n reject(new Error('Upload response did not include a storageId.'))\n }\n } catch {\n reject(new Error('Upload response was not valid JSON.'))\n }\n }\n void file.arrayBuffer().then(\n (bytes) => xhr.send(bytes),\n () => reject(new Error('Could not read file bytes.')),\n )\n })\n\nexport interface UploadDeps {\n blobs: BlobStorageAdapter\n assets: AssetRepository\n /** Client-direct transport; only used when `blobs.createUploadUrl` exists. */\n transport?: UploadTransport\n /** Injectable dimension detection; defaults to `createImageBitmap`-based. */\n detectDimensions?: (file: UploadableFile) => Promise<ImageDimensions | undefined>\n /**\n * Injectable preview generation (EC-400 thumb + EC-420 lqip). Defaults to a\n * single `createImageBitmap` + canvas decode that stores the thumb via a second\n * `blobs.store()` and inlines the lqip data URI. Returns `{}` (or a partial) for\n * skipped/failed variants — a preview NEVER fails the upload (AD-2). Tests inject\n * a deterministic implementation.\n */\n generateVariants?: (\n file: UploadableFile,\n dimensions: ImageDimensions | undefined,\n ) => Promise<GeneratedVariants>\n}\n\nexport interface UploadRequest {\n projectId: string\n file: UploadableFile\n alt?: string\n /** Observe every state transition (drives progress UI). */\n onState?: (state: UploadState) => void\n}\n\nexport type UploadResult =\n | { ok: true; asset: AssetRecord; state: UploadState }\n | { ok: false; failure: UploadFailure; state: UploadState }\n\nasync function defaultDetectDimensions(file: UploadableFile): Promise<ImageDimensions | undefined> {\n if (typeof Blob === 'undefined' || !(file instanceof Blob)) return undefined\n return detectImageDimensions(file as Blob & { type: string })\n}\n\n/**\n * Default preview step (EC-400 thumb + EC-420 lqip): decode the real `File` (which\n * IS a `Blob` in the browser) ONCE and produce both variants — store the thumb on\n * `blobs`, inline the lqip data URI. Guards the `Blob` narrowing so a non-browser\n * `UploadableFile` (or a backend without `store()`) simply yields no thumb — never\n * an error that could fail the upload.\n */\nfunction defaultGenerateVariants(blobs: BlobStorageAdapter) {\n return async (\n file: UploadableFile,\n dimensions: ImageDimensions | undefined,\n ): Promise<GeneratedVariants> => {\n if (typeof Blob === 'undefined' || !(file instanceof Blob)) return {}\n return generateVariants({ blobs }, file, {\n contentType: file.type,\n ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),\n })\n }\n}\n\nfunction failureOf(stage: UploadFailure['stage'], error: unknown): UploadFailure {\n return {\n stage,\n message: error instanceof Error ? error.message : String(error),\n retryable: true,\n }\n}\n\n/**\n * Run one upload attempt. Resolves with a discriminated result — it never\n * throws, and a failed transfer leaves no metadata record behind. Pass the\n * previous `state` to retry (the attempt counter carries over).\n */\nexport async function runUpload(\n deps: UploadDeps,\n request: UploadRequest,\n previous?: UploadState,\n): Promise<UploadResult> {\n let state = previous ?? initialUploadState\n const dispatch = (event: UploadEvent): void => {\n state = uploadReducer(state, event)\n request.onState?.(state)\n }\n dispatch(state.phase === 'failed' ? { type: 'retry' } : { type: 'start' })\n\n const { blobs, assets } = deps\n const { projectId, file } = request\n\n // --- prepare + transfer ---------------------------------------------------\n let storageKey: string\n try {\n if (blobs.createUploadUrl && deps.transport) {\n const uploadUrl = await blobs.createUploadUrl()\n dispatch({ type: 'url-ready' })\n try {\n const sent = await deps.transport(uploadUrl, file, (loaded, total) =>\n dispatch({ type: 'progress', loaded, total }),\n )\n storageKey = sent.storageKey\n } catch (error) {\n dispatch({ type: 'failed', ...failureOf('transfer', error) })\n return { ok: false, failure: failureOf('transfer', error), state }\n }\n } else {\n // Backend without client-direct uploads: push bytes through the adapter.\n dispatch({ type: 'url-ready' })\n const bytes = new Uint8Array(await file.arrayBuffer())\n try {\n if (blobs.store) {\n storageKey = await blobs.store(bytes, file.type)\n } else {\n storageKey = `${projectId}/${file.name}`\n await blobs.put(storageKey, bytes, file.type)\n }\n } catch (error) {\n dispatch({ type: 'failed', ...failureOf('transfer', error) })\n return { ok: false, failure: failureOf('transfer', error), state }\n }\n }\n } catch (error) {\n dispatch({ type: 'failed', ...failureOf('prepare', error) })\n return { ok: false, failure: failureOf('prepare', error), state }\n }\n dispatch({ type: 'transferred' })\n\n // --- confirm: metadata record linked to the stored blob --------------------\n try {\n const detect = deps.detectDimensions ?? defaultDetectDimensions\n const dimensions = await detect(file)\n // EC-400 + EC-420: produce the thumb + lqip previews from the bytes already in\n // hand (one decode). A preview failure is swallowed — the asset simply ships\n // without them (AD-2, scope §4); absence = today's behaviour, never an error.\n const generate = deps.generateVariants ?? defaultGenerateVariants(blobs)\n let variants: GeneratedVariants\n try {\n variants = await generate(file, dimensions)\n } catch {\n variants = {}\n }\n const url = (await blobs.resolveUrl?.(storageKey)) ?? blobs.url(storageKey)\n const asset = await assets.upsertAsset(projectId, {\n filename: file.name,\n contentType: file.type || 'application/octet-stream',\n byteSize: file.size,\n storageKey,\n url,\n ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),\n ...(variants.thumb ? { thumb: variants.thumb } : {}),\n ...(variants.lqip ? { lqip: variants.lqip } : {}),\n ...(request.alt !== undefined ? { alt: request.alt } : {}),\n })\n dispatch({ type: 'confirmed', assetId: asset.id })\n return { ok: true, asset, state }\n } catch (error) {\n dispatch({ type: 'failed', ...failureOf('confirm', error) })\n return { ok: false, failure: failureOf('confirm', error), state }\n }\n}\n"]}
|
|
@@ -27,6 +27,18 @@ export interface AuthUser {
|
|
|
27
27
|
export interface AuthSession {
|
|
28
28
|
readonly user: AuthUser;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Tri-state outcome of a proactive session probe (EC-415).
|
|
32
|
+
*
|
|
33
|
+
* - `valid` — the backend confirmed the session.
|
|
34
|
+
* - `invalid` — the backend ANSWERED and rejected/lost the session (expired or
|
|
35
|
+
* revoked token, deleted server-side session). The adapter clears its stored
|
|
36
|
+
* token; callers may raise the re-auth treatment.
|
|
37
|
+
* - `unreachable` — no authoritative answer (network blip, backend down).
|
|
38
|
+
* The stored token MUST survive and callers MUST NOT treat this as
|
|
39
|
+
* signed-out — recovery is silent once the backend returns.
|
|
40
|
+
*/
|
|
41
|
+
export type SessionProbeResult = 'valid' | 'invalid' | 'unreachable';
|
|
30
42
|
export interface SignInCredentials {
|
|
31
43
|
readonly email: string;
|
|
32
44
|
readonly password: string;
|
|
@@ -76,6 +88,16 @@ export type ChangePasswordResult = {
|
|
|
76
88
|
export interface AuthAdapter {
|
|
77
89
|
/** Restore the current session, if any (page load / refresh). */
|
|
78
90
|
currentSession(): Promise<AuthSession | null>;
|
|
91
|
+
/**
|
|
92
|
+
* Proactively re-check the current session (EC-415) WITHOUT the destructive
|
|
93
|
+
* catch-all of {@link currentSession} (which treats ANY failure — including a
|
|
94
|
+
* transient network drop — as signed-out and clears the stored token, so a
|
|
95
|
+
* focus/interval poll built on it would sign editors out on a network blip).
|
|
96
|
+
* The probe distinguishes the cases: only `invalid` (the backend answered and
|
|
97
|
+
* rejected/lost the session) clears the token; `unreachable` never clears it.
|
|
98
|
+
* Must never throw — an unclassifiable failure is `unreachable`.
|
|
99
|
+
*/
|
|
100
|
+
probeSession(): Promise<SessionProbeResult>;
|
|
79
101
|
/** Password sign-in. Resolves a structured result; never throws for bad input. */
|
|
80
102
|
signIn(credentials: SignInCredentials): Promise<SignInResult>;
|
|
81
103
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-adapter.js","sourceRoot":"","sources":["../../../src/lib/auth/auth-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG","sourcesContent":["/**\n * Project-owned auth boundary (EC-150, vision AD-7). The studio talks to\n * authentication ONLY through {@link AuthAdapter} — the Convex Auth\n * implementation (`convex-auth-adapter.ts`) and the deterministic local\n * implementation (`local-auth-adapter.ts`) both satisfy it, mirroring how\n * `PersistenceAdapter` keeps Convex out of the app's type graph.\n *\n * Swapping providers (e.g. Convex Auth → Clerk) means writing one new\n * implementation of this interface and teaching `selectAuthAdapter` to pick\n * it; no UI or store code changes.\n */\n\n/** The signed-in user as the studio sees it. */\nexport interface AuthUser {\n /** Stable user id — the key `projectMembers.userId` is matched against. */\n readonly id: string\n readonly email: string\n readonly name?: string\n /**\n * Global (workspace-level) capability flags forwarded onto the operations\n * `Principal.roles`, e.g. `system` (internal tooling only). Per-project roles\n * do NOT live here — they live in the members repository. Currently no\n * browser-session flag is granted: project creation is config/CLI-owned\n * (AD-11), so there is no `create-projects` capability.\n */\n readonly globalFlags?: readonly string[]\n}\n\nexport interface AuthSession {\n readonly user: AuthUser\n}\n\nexport interface SignInCredentials {\n readonly email: string\n readonly password: string\n}\n\n/**\n * Structured sign-in outcome — invalid credentials are a state, not a throw.\n *\n * `deployment-misconfigured` (EC-257) is distinct from `invalid-credentials`: the\n * deployment can't sign the session JWT (missing `JWT_PRIVATE_KEY`/`JWKS`/\n * `CONVEX_SITE_URL`, or a malformed signing key), so EVERY sign-in fails regardless\n * of the password. Surfacing it as its own reason lets the sign-in screen tell the\n * truth (\"run `npx @convex-dev/auth`\") instead of the old lying \"password too short\".\n */\nexport type SignInResult =\n | { readonly ok: true; readonly session: AuthSession }\n | {\n readonly ok: false\n readonly reason:\n | 'invalid-credentials'\n | 'account-exists'\n | 'deployment-misconfigured'\n // Onboarding is invite-based (EC-324): a sign-up whose email is neither the\n // first-user bootstrap nor on the admin's invite allowlist is rejected\n // server-side. Distinct from `invalid-credentials` so the screen tells the\n // truth (\"ask an admin to invite you\") instead of \"password too short\".\n | 'not-invited'\n | 'unavailable'\n readonly message: string\n }\n\n/** Self-service identity edit (EC-240 H-tail item 2) — auth-owned name/avatar. */\nexport interface UpdateProfileInput {\n readonly name?: string\n readonly avatar?: string\n}\n\nexport type UpdateProfileResult =\n | { readonly ok: true; readonly session: AuthSession }\n | {\n readonly ok: false\n readonly reason: 'not-authenticated' | 'unavailable' | 'unsupported'\n readonly message: string\n }\n\n/** Self-service password change (EC-240 H-tail item 3) — provider-delegated. */\nexport interface ChangePasswordInput {\n readonly currentPassword: string\n readonly newPassword: string\n}\n\nexport type ChangePasswordResult =\n | { readonly ok: true }\n | {\n readonly ok: false\n readonly reason:\n | 'invalid-credentials'\n | 'weak-password'\n | 'not-authenticated'\n | 'unavailable'\n | 'unsupported'\n readonly message: string\n }\n\nexport interface AuthAdapter {\n /** Restore the current session, if any (page load / refresh). */\n currentSession(): Promise<AuthSession | null>\n /** Password sign-in. Resolves a structured result; never throws for bad input. */\n signIn(credentials: SignInCredentials): Promise<SignInResult>\n /**\n * Create a password account and sign in (EC-179). One project per backend, so\n * a freshly-provisioned deployment starts with no users — the first member\n * registers here, then is granted a role. `account-exists` is a state, not a\n * throw.\n */\n signUp(credentials: SignInCredentials): Promise<SignInResult>\n /** End the session (idempotent). */\n signOut(): Promise<void>\n /**\n * Update the signed-in user's OWN identity — name / avatar (EC-240 H-tail item\n * 2). These are AUTH-owned (the provider's `users` row), so they flow through\n * the adapter, not the members-rail `users.save`. Self-scoped: the provider\n * patches only the caller's record. Returns the refreshed session so the shell\n * reflects the new name immediately.\n */\n updateProfile(input: UpdateProfileInput): Promise<UpdateProfileResult>\n /**\n * Change the signed-in user's OWN password (EC-240 H-tail item 3), gated by the\n * current password. **Seam only:** the raw credential values are handed straight\n * to the provider adapter — never logged, stored, or inspected by app code, and\n * never routed through the operations layer. Self-scoped.\n */\n changePassword(input: ChangePasswordInput): Promise<ChangePasswordResult>\n}\n"]}
|
|
1
|
+
{"version":3,"file":"auth-adapter.js","sourceRoot":"","sources":["../../../src/lib/auth/auth-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG","sourcesContent":["/**\n * Project-owned auth boundary (EC-150, vision AD-7). The studio talks to\n * authentication ONLY through {@link AuthAdapter} — the Convex Auth\n * implementation (`convex-auth-adapter.ts`) and the deterministic local\n * implementation (`local-auth-adapter.ts`) both satisfy it, mirroring how\n * `PersistenceAdapter` keeps Convex out of the app's type graph.\n *\n * Swapping providers (e.g. Convex Auth → Clerk) means writing one new\n * implementation of this interface and teaching `selectAuthAdapter` to pick\n * it; no UI or store code changes.\n */\n\n/** The signed-in user as the studio sees it. */\nexport interface AuthUser {\n /** Stable user id — the key `projectMembers.userId` is matched against. */\n readonly id: string\n readonly email: string\n readonly name?: string\n /**\n * Global (workspace-level) capability flags forwarded onto the operations\n * `Principal.roles`, e.g. `system` (internal tooling only). Per-project roles\n * do NOT live here — they live in the members repository. Currently no\n * browser-session flag is granted: project creation is config/CLI-owned\n * (AD-11), so there is no `create-projects` capability.\n */\n readonly globalFlags?: readonly string[]\n}\n\nexport interface AuthSession {\n readonly user: AuthUser\n}\n\n/**\n * Tri-state outcome of a proactive session probe (EC-415).\n *\n * - `valid` — the backend confirmed the session.\n * - `invalid` — the backend ANSWERED and rejected/lost the session (expired or\n * revoked token, deleted server-side session). The adapter clears its stored\n * token; callers may raise the re-auth treatment.\n * - `unreachable` — no authoritative answer (network blip, backend down).\n * The stored token MUST survive and callers MUST NOT treat this as\n * signed-out — recovery is silent once the backend returns.\n */\nexport type SessionProbeResult = 'valid' | 'invalid' | 'unreachable'\n\nexport interface SignInCredentials {\n readonly email: string\n readonly password: string\n}\n\n/**\n * Structured sign-in outcome — invalid credentials are a state, not a throw.\n *\n * `deployment-misconfigured` (EC-257) is distinct from `invalid-credentials`: the\n * deployment can't sign the session JWT (missing `JWT_PRIVATE_KEY`/`JWKS`/\n * `CONVEX_SITE_URL`, or a malformed signing key), so EVERY sign-in fails regardless\n * of the password. Surfacing it as its own reason lets the sign-in screen tell the\n * truth (\"run `npx @convex-dev/auth`\") instead of the old lying \"password too short\".\n */\nexport type SignInResult =\n | { readonly ok: true; readonly session: AuthSession }\n | {\n readonly ok: false\n readonly reason:\n | 'invalid-credentials'\n | 'account-exists'\n | 'deployment-misconfigured'\n // Onboarding is invite-based (EC-324): a sign-up whose email is neither the\n // first-user bootstrap nor on the admin's invite allowlist is rejected\n // server-side. Distinct from `invalid-credentials` so the screen tells the\n // truth (\"ask an admin to invite you\") instead of \"password too short\".\n | 'not-invited'\n | 'unavailable'\n readonly message: string\n }\n\n/** Self-service identity edit (EC-240 H-tail item 2) — auth-owned name/avatar. */\nexport interface UpdateProfileInput {\n readonly name?: string\n readonly avatar?: string\n}\n\nexport type UpdateProfileResult =\n | { readonly ok: true; readonly session: AuthSession }\n | {\n readonly ok: false\n readonly reason: 'not-authenticated' | 'unavailable' | 'unsupported'\n readonly message: string\n }\n\n/** Self-service password change (EC-240 H-tail item 3) — provider-delegated. */\nexport interface ChangePasswordInput {\n readonly currentPassword: string\n readonly newPassword: string\n}\n\nexport type ChangePasswordResult =\n | { readonly ok: true }\n | {\n readonly ok: false\n readonly reason:\n | 'invalid-credentials'\n | 'weak-password'\n | 'not-authenticated'\n | 'unavailable'\n | 'unsupported'\n readonly message: string\n }\n\nexport interface AuthAdapter {\n /** Restore the current session, if any (page load / refresh). */\n currentSession(): Promise<AuthSession | null>\n /**\n * Proactively re-check the current session (EC-415) WITHOUT the destructive\n * catch-all of {@link currentSession} (which treats ANY failure — including a\n * transient network drop — as signed-out and clears the stored token, so a\n * focus/interval poll built on it would sign editors out on a network blip).\n * The probe distinguishes the cases: only `invalid` (the backend answered and\n * rejected/lost the session) clears the token; `unreachable` never clears it.\n * Must never throw — an unclassifiable failure is `unreachable`.\n */\n probeSession(): Promise<SessionProbeResult>\n /** Password sign-in. Resolves a structured result; never throws for bad input. */\n signIn(credentials: SignInCredentials): Promise<SignInResult>\n /**\n * Create a password account and sign in (EC-179). One project per backend, so\n * a freshly-provisioned deployment starts with no users — the first member\n * registers here, then is granted a role. `account-exists` is a state, not a\n * throw.\n */\n signUp(credentials: SignInCredentials): Promise<SignInResult>\n /** End the session (idempotent). */\n signOut(): Promise<void>\n /**\n * Update the signed-in user's OWN identity — name / avatar (EC-240 H-tail item\n * 2). These are AUTH-owned (the provider's `users` row), so they flow through\n * the adapter, not the members-rail `users.save`. Self-scoped: the provider\n * patches only the caller's record. Returns the refreshed session so the shell\n * reflects the new name immediately.\n */\n updateProfile(input: UpdateProfileInput): Promise<UpdateProfileResult>\n /**\n * Change the signed-in user's OWN password (EC-240 H-tail item 3), gated by the\n * current password. **Seam only:** the raw credential values are handed straight\n * to the provider adapter — never logged, stored, or inspected by app code, and\n * never routed through the operations layer. Self-scoped.\n */\n changePassword(input: ChangePasswordInput): Promise<ChangePasswordResult>\n}\n"]}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ConvexHttpClient } from 'convex/browser';
|
|
2
|
-
import type { AuthAdapter, AuthSession, ChangePasswordInput, ChangePasswordResult, SignInCredentials, SignInResult, UpdateProfileInput, UpdateProfileResult } from './auth-adapter.js';
|
|
2
|
+
import type { AuthAdapter, AuthSession, ChangePasswordInput, ChangePasswordResult, SessionProbeResult, SignInCredentials, SignInResult, UpdateProfileInput, UpdateProfileResult } from './auth-adapter.js';
|
|
3
3
|
import type { SessionStore } from './local-auth-adapter.js';
|
|
4
4
|
/**
|
|
5
5
|
* Classify a thrown sign-in error as a DEPLOYMENT-setup failure rather than a
|
|
@@ -41,6 +41,35 @@ export declare function isDeploymentMisconfigured(message: string): boolean;
|
|
|
41
41
|
* string (our gates) or a structured object (future gates) — both are folded in.
|
|
42
42
|
*/
|
|
43
43
|
export declare function convexErrorClassifierText(error: unknown): string;
|
|
44
|
+
/**
|
|
45
|
+
* Does this transport-error text carry Convex's POSITIVE "the token was
|
|
46
|
+
* rejected" signal? (EC-415 probe classification.)
|
|
47
|
+
*
|
|
48
|
+
* Derivation (drift-proof, from the real transport): a rejected JWT on
|
|
49
|
+
* `/api/query` makes the Convex backend answer HTTP 401 with a raw blob the
|
|
50
|
+
* installed `convex@1.42.1` `ConvexHttpClient` re-throws VERBATIM as
|
|
51
|
+
* `new Error(await response.text())` (`dist/esm/browser/http_client.js`,
|
|
52
|
+
* `queryInner`: `if (!response.ok && response.status !== STATUS_CODE_UDF_FAILED)
|
|
53
|
+
* throw new Error(await response.text())`). Two rejection codes exist, both
|
|
54
|
+
* observed against a REAL deployment:
|
|
55
|
+
*
|
|
56
|
+
* - `{"code":"Unauthenticated","message":"Could not verify OIDC token claim: …"}`
|
|
57
|
+
* — an EXPIRED/revoked (but well-signed) session. The SAME markers
|
|
58
|
+
* `lib/auth-errors.ts#isSessionExpiredError` pins for the reactive path
|
|
59
|
+
* (EC-403).
|
|
60
|
+
* - `{"code":"InvalidAuthHeader","message":"Could not parse JWT payload…"}` /
|
|
61
|
+
* `"Could not parse as OIDC ID token…"` — a malformed or unverifiable token
|
|
62
|
+
* (captured LIVE from a convex.cloud deployment during EC-415 verification).
|
|
63
|
+
* Equally authoritative: the backend answered and positively rejected the
|
|
64
|
+
* credential; keeping such a token would just wedge every future probe.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately narrow: an UNREACHABLE backend never produces this text — there
|
|
67
|
+
* `fetch` itself rejects (a `TypeError`: "Failed to fetch" in Chromium,
|
|
68
|
+
* "Load failed" in WebKit, "fetch failed" in Node/undici) and the client
|
|
69
|
+
* propagates that rejection unwrapped. Anything not positively matched here is
|
|
70
|
+
* treated as `unreachable`, which can never clear a valid token.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isUnauthenticatedTransportText(text: string): boolean;
|
|
44
73
|
type SignInFailure = Extract<SignInResult, {
|
|
45
74
|
ok: false;
|
|
46
75
|
}>;
|
|
@@ -110,6 +139,18 @@ export declare class ConvexAuthAdapter implements AuthAdapter {
|
|
|
110
139
|
/** Deployment URL the token is scoped to (per-backend sessions, EC-179). */
|
|
111
140
|
deploymentUrl?: string);
|
|
112
141
|
currentSession(): Promise<AuthSession | null>;
|
|
142
|
+
/**
|
|
143
|
+
* Tri-state session probe (EC-415). Same `users:me` round-trip as
|
|
144
|
+
* {@link currentSession}, but WITHOUT its destructive catch-all: only a
|
|
145
|
+
* POSITIVE token rejection from the backend ({@link
|
|
146
|
+
* isUnauthenticatedTransportText} over the data-first error text) — or the
|
|
147
|
+
* backend answering "no identity" (`users:me` → `null`, e.g. the server-side
|
|
148
|
+
* session row was deleted) — clears the stored token. Every other failure
|
|
149
|
+
* (fetch rejection, 5xx, unclassifiable) is `unreachable`: the token survives
|
|
150
|
+
* so a network blip can never sign an editor out. `currentSession()` keeps
|
|
151
|
+
* its existing external behavior for its callers; never throws.
|
|
152
|
+
*/
|
|
153
|
+
probeSession(): Promise<SessionProbeResult>;
|
|
113
154
|
signIn(credentials: SignInCredentials): Promise<SignInResult>;
|
|
114
155
|
signUp(credentials: SignInCredentials): Promise<SignInResult>;
|
|
115
156
|
/**
|
|
@@ -87,6 +87,39 @@ export function convexErrorClassifierText(error) {
|
|
|
87
87
|
parts.push(String(error));
|
|
88
88
|
return parts.join('\n');
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Does this transport-error text carry Convex's POSITIVE "the token was
|
|
92
|
+
* rejected" signal? (EC-415 probe classification.)
|
|
93
|
+
*
|
|
94
|
+
* Derivation (drift-proof, from the real transport): a rejected JWT on
|
|
95
|
+
* `/api/query` makes the Convex backend answer HTTP 401 with a raw blob the
|
|
96
|
+
* installed `convex@1.42.1` `ConvexHttpClient` re-throws VERBATIM as
|
|
97
|
+
* `new Error(await response.text())` (`dist/esm/browser/http_client.js`,
|
|
98
|
+
* `queryInner`: `if (!response.ok && response.status !== STATUS_CODE_UDF_FAILED)
|
|
99
|
+
* throw new Error(await response.text())`). Two rejection codes exist, both
|
|
100
|
+
* observed against a REAL deployment:
|
|
101
|
+
*
|
|
102
|
+
* - `{"code":"Unauthenticated","message":"Could not verify OIDC token claim: …"}`
|
|
103
|
+
* — an EXPIRED/revoked (but well-signed) session. The SAME markers
|
|
104
|
+
* `lib/auth-errors.ts#isSessionExpiredError` pins for the reactive path
|
|
105
|
+
* (EC-403).
|
|
106
|
+
* - `{"code":"InvalidAuthHeader","message":"Could not parse JWT payload…"}` /
|
|
107
|
+
* `"Could not parse as OIDC ID token…"` — a malformed or unverifiable token
|
|
108
|
+
* (captured LIVE from a convex.cloud deployment during EC-415 verification).
|
|
109
|
+
* Equally authoritative: the backend answered and positively rejected the
|
|
110
|
+
* credential; keeping such a token would just wedge every future probe.
|
|
111
|
+
*
|
|
112
|
+
* Deliberately narrow: an UNREACHABLE backend never produces this text — there
|
|
113
|
+
* `fetch` itself rejects (a `TypeError`: "Failed to fetch" in Chromium,
|
|
114
|
+
* "Load failed" in WebKit, "fetch failed" in Node/undici) and the client
|
|
115
|
+
* propagates that rejection unwrapped. Anything not positively matched here is
|
|
116
|
+
* treated as `unreachable`, which can never clear a valid token.
|
|
117
|
+
*/
|
|
118
|
+
export function isUnauthenticatedTransportText(text) {
|
|
119
|
+
return (/\bUnauthenticated\b/.test(text) ||
|
|
120
|
+
/\bInvalidAuthHeader\b/.test(text) ||
|
|
121
|
+
/could not verify oidc token/i.test(text));
|
|
122
|
+
}
|
|
90
123
|
/**
|
|
91
124
|
* Pure sign-in/sign-up error classifier (EC-257 / EC-324 / EC-362 / EC-363).
|
|
92
125
|
* Runs the regex ladder over {@link convexErrorClassifierText} output
|
|
@@ -292,6 +325,42 @@ export class ConvexAuthAdapter {
|
|
|
292
325
|
return null;
|
|
293
326
|
}
|
|
294
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* Tri-state session probe (EC-415). Same `users:me` round-trip as
|
|
330
|
+
* {@link currentSession}, but WITHOUT its destructive catch-all: only a
|
|
331
|
+
* POSITIVE token rejection from the backend ({@link
|
|
332
|
+
* isUnauthenticatedTransportText} over the data-first error text) — or the
|
|
333
|
+
* backend answering "no identity" (`users:me` → `null`, e.g. the server-side
|
|
334
|
+
* session row was deleted) — clears the stored token. Every other failure
|
|
335
|
+
* (fetch rejection, 5xx, unclassifiable) is `unreachable`: the token survives
|
|
336
|
+
* so a network blip can never sign an editor out. `currentSession()` keeps
|
|
337
|
+
* its existing external behavior for its callers; never throws.
|
|
338
|
+
*/
|
|
339
|
+
async probeSession() {
|
|
340
|
+
const token = this.store.getItem(this.tokenKey);
|
|
341
|
+
if (!token)
|
|
342
|
+
return 'invalid';
|
|
343
|
+
this.client.setAuth(token);
|
|
344
|
+
try {
|
|
345
|
+
const me = (await this.client.query(queryRef('users:me'), {}));
|
|
346
|
+
if (!me) {
|
|
347
|
+
// The backend answered: the token verified but no identity exists
|
|
348
|
+
// (session revoked/deleted server-side) — the session is dead.
|
|
349
|
+
this.clearToken();
|
|
350
|
+
return 'invalid';
|
|
351
|
+
}
|
|
352
|
+
return 'valid';
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
if (isUnauthenticatedTransportText(convexErrorClassifierText(error))) {
|
|
356
|
+
this.clearToken();
|
|
357
|
+
return 'invalid';
|
|
358
|
+
}
|
|
359
|
+
// No authoritative answer — NEVER clear the token here (the EC-403
|
|
360
|
+
// finding's exact hazard: a focus poll signing editors out on a blip).
|
|
361
|
+
return 'unreachable';
|
|
362
|
+
}
|
|
363
|
+
}
|
|
295
364
|
async signIn(credentials) {
|
|
296
365
|
return this.authenticate('signIn', credentials);
|
|
297
366
|
}
|