@storystick/preview 0.0.0-stage → 0.2.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +112 -0
- package/PUBLIC-API.md +55 -0
- package/README.md +42 -2
- package/dist/.source-hash +1 -0
- package/dist/authoring/runtime-contract.d.ts +89 -0
- package/dist/authoring/runtime-contract.js +28 -0
- package/dist/channel-taxonomy.d.ts +17 -0
- package/dist/channel-taxonomy.js +46 -0
- package/dist/context-command.d.ts +18 -0
- package/dist/context-command.js +38 -0
- package/dist/dev/suspicious-codeword-detector.d.ts +15 -0
- package/dist/dev/suspicious-codeword-detector.js +220 -0
- package/dist/dev/suspicious-codeword-detector.noop.d.ts +3 -0
- package/dist/dev/suspicious-codeword-detector.noop.js +4 -0
- package/dist/framework/browser-context.d.ts +2 -0
- package/dist/framework/browser-context.js +2 -0
- package/dist/framework/generation-refresh.d.ts +23 -0
- package/dist/framework/generation-refresh.js +82 -0
- package/dist/framework/generation-signal.d.ts +8 -0
- package/dist/framework/generation-signal.js +17 -0
- package/dist/framework/projection-generation-watch.d.ts +5 -0
- package/dist/framework/projection-generation-watch.js +53 -0
- package/dist/framework/render-receipt.d.ts +8 -0
- package/dist/framework/render-receipt.js +10 -0
- package/dist/framework/runtime-adapter-store.d.ts +30 -0
- package/dist/framework/runtime-adapter-store.js +81 -0
- package/dist/grant-coordinator.d.ts +53 -0
- package/dist/grant-coordinator.js +329 -0
- package/dist/grant-lifecycle.d.ts +52 -0
- package/dist/grant-lifecycle.js +74 -0
- package/dist/identity/__test-adapter.d.ts +34 -0
- package/dist/identity/__test-adapter.js +59 -0
- package/dist/identity/browser-context-gate.d.ts +4 -0
- package/dist/identity/browser-context-gate.js +22 -0
- package/dist/identity/browser-context.d.ts +13 -0
- package/dist/identity/browser-context.js +22 -0
- package/dist/identity/carriers/postmessage.d.ts +84 -0
- package/dist/identity/carriers/postmessage.js +177 -0
- package/dist/identity/carriers/storage-state.d.ts +41 -0
- package/dist/identity/carriers/storage-state.js +99 -0
- package/dist/identity/carriers/storage.d.ts +39 -0
- package/dist/identity/carriers/storage.js +182 -0
- package/dist/identity/carriers/url.d.ts +19 -0
- package/dist/identity/carriers/url.js +38 -0
- package/dist/identity/identity-state-machine.d.ts +70 -0
- package/dist/identity/identity-state-machine.js +244 -0
- package/dist/identity/identity-types.d.ts +192 -0
- package/dist/identity/identity-types.js +22 -0
- package/dist/identity/index.d.ts +18 -0
- package/dist/identity/index.js +16 -0
- package/dist/identity/preview-cookie.d.ts +9 -0
- package/dist/identity/preview-cookie.js +12 -0
- package/dist/identity/preview-draft.d.ts +6 -0
- package/dist/identity/preview-draft.js +25 -0
- package/dist/identity/preview-identity.d.ts +28 -0
- package/dist/identity/preview-identity.js +84 -0
- package/dist/identity/preview-scope.d.ts +28 -0
- package/dist/identity/preview-scope.js +47 -0
- package/dist/identity/resolver.d.ts +110 -0
- package/dist/identity/resolver.js +334 -0
- package/dist/identity/session-query.d.ts +4 -0
- package/dist/identity/session-query.js +19 -0
- package/dist/index.d.ts +90 -0
- package/dist/index.js +788 -0
- package/dist/interaction-mode.d.ts +58 -0
- package/dist/interaction-mode.js +93 -0
- package/dist/nav-handler.d.ts +95 -0
- package/dist/nav-handler.js +84 -0
- package/dist/overlay-label.d.ts +79 -0
- package/dist/overlay-label.js +73 -0
- package/dist/provenance/anchor-map.d.ts +95 -0
- package/dist/provenance/anchor-map.js +177 -0
- package/dist/provenance/carrier-plan.d.ts +51 -0
- package/dist/provenance/carrier-plan.js +90 -0
- package/dist/provenance/coded-text.d.ts +20 -0
- package/dist/provenance/coded-text.js +302 -0
- package/dist/provenance/direct-edit-locator.d.ts +29 -0
- package/dist/provenance/direct-edit-locator.js +218 -0
- package/dist/provenance/editable-leaf.d.ts +134 -0
- package/dist/provenance/editable-leaf.js +483 -0
- package/dist/provenance/editor-target.d.ts +29 -0
- package/dist/provenance/editor-target.js +46 -0
- package/dist/provenance/field-leaf-walker.d.ts +21 -0
- package/dist/provenance/field-leaf-walker.js +98 -0
- package/dist/provenance/frame-dependencies.d.ts +212 -0
- package/dist/provenance/frame-dependencies.js +550 -0
- package/dist/provenance/index.d.ts +26 -0
- package/dist/provenance/index.js +26 -0
- package/dist/provenance/local-events.d.ts +1 -0
- package/dist/provenance/local-events.js +1 -0
- package/dist/provenance/markers.d.ts +21 -0
- package/dist/provenance/markers.js +70 -0
- package/dist/provenance/multi-frame-anchor-map.d.ts +54 -0
- package/dist/provenance/multi-frame-anchor-map.js +109 -0
- package/dist/provenance/normalize.d.ts +2 -0
- package/dist/provenance/normalize.js +19 -0
- package/dist/provenance/overlay-focus-echo.d.ts +17 -0
- package/dist/provenance/overlay-focus-echo.js +16 -0
- package/dist/provenance/overlay-projection.d.ts +65 -0
- package/dist/provenance/overlay-projection.js +201 -0
- package/dist/provenance/owner-map.d.ts +55 -0
- package/dist/provenance/owner-map.js +239 -0
- package/dist/provenance/read-carrier.d.ts +22 -0
- package/dist/provenance/read-carrier.js +92 -0
- package/dist/provenance/recorder.d.ts +38 -0
- package/dist/provenance/recorder.js +82 -0
- package/dist/provenance/render-context.d.ts +106 -0
- package/dist/provenance/render-context.js +338 -0
- package/dist/provenance/reveal-contract.d.ts +44 -0
- package/dist/provenance/reveal-contract.js +1 -0
- package/dist/provenance/richtext-slice-embed-address.d.ts +20 -0
- package/dist/provenance/richtext-slice-embed-address.js +35 -0
- package/dist/provenance/runtime-script.d.ts +1 -0
- package/dist/provenance/runtime-script.js +2217 -0
- package/dist/provenance/sidecar-store.d.ts +63 -0
- package/dist/provenance/sidecar-store.js +151 -0
- package/dist/provenance/sidecar.d.ts +34 -0
- package/dist/provenance/sidecar.js +227 -0
- package/dist/provenance/slice-model.d.ts +28 -0
- package/dist/provenance/slice-model.js +86 -0
- package/dist/provenance/source-identity.d.ts +3 -0
- package/dist/provenance/source-identity.js +7 -0
- package/dist/provenance/source-map-materializer.d.ts +76 -0
- package/dist/provenance/source-map-materializer.js +286 -0
- package/dist/provenance/source-registry.d.ts +103 -0
- package/dist/provenance/source-registry.js +562 -0
- package/dist/provenance/transformed-output-gate.d.ts +161 -0
- package/dist/provenance/transformed-output-gate.js +171 -0
- package/dist/provenance/types.d.ts +102 -0
- package/dist/provenance/types.js +1 -0
- package/dist/refresh-policy.d.ts +11 -0
- package/dist/refresh-policy.js +14 -0
- package/dist/reveal-target.d.ts +8 -0
- package/dist/reveal-target.js +47 -0
- package/dist/route-paths.d.ts +11 -0
- package/dist/route-paths.js +14 -0
- package/dist/router-subscriber.d.ts +92 -0
- package/dist/router-subscriber.js +44 -0
- package/dist/runtime/anchor-types.d.ts +5 -0
- package/dist/runtime/anchor-types.js +1 -0
- package/dist/runtime/authority-store.d.ts +31 -0
- package/dist/runtime/authority-store.js +56 -0
- package/dist/runtime/boot.d.ts +52 -0
- package/dist/runtime/boot.js +379 -0
- package/dist/runtime/content-refresh.d.ts +30 -0
- package/dist/runtime/content-refresh.js +68 -0
- package/dist/runtime/emit-runtime-error.d.ts +51 -0
- package/dist/runtime/emit-runtime-error.js +64 -0
- package/dist/runtime/field-proxy.d.ts +112 -0
- package/dist/runtime/field-proxy.js +471 -0
- package/dist/runtime/form-contract-reporter.d.ts +99 -0
- package/dist/runtime/form-contract-reporter.js +391 -0
- package/dist/runtime/invalidation-adapter.d.ts +17 -0
- package/dist/runtime/invalidation-adapter.js +42 -0
- package/dist/runtime/live-query-overlay.d.ts +39 -0
- package/dist/runtime/live-query-overlay.js +345 -0
- package/dist/runtime/momentary-navigate.d.ts +16 -0
- package/dist/runtime/momentary-navigate.js +106 -0
- package/dist/runtime/observation-scheduler.d.ts +15 -0
- package/dist/runtime/observation-scheduler.js +42 -0
- package/dist/runtime/perf-metrics.d.ts +28 -0
- package/dist/runtime/perf-metrics.js +90 -0
- package/dist/runtime/preview-interaction-policy.d.ts +1 -0
- package/dist/runtime/preview-interaction-policy.js +103 -0
- package/dist/runtime/preview-overlay-painter.d.ts +27 -0
- package/dist/runtime/preview-overlay-painter.js +1732 -0
- package/dist/runtime/preview-overlay-sidecar.d.ts +26 -0
- package/dist/runtime/preview-overlay-sidecar.js +45 -0
- package/dist/runtime/preview-perspective-alignment.d.ts +15 -0
- package/dist/runtime/preview-perspective-alignment.js +40 -0
- package/dist/runtime/preview-perspective-intent-state.d.ts +23 -0
- package/dist/runtime/preview-perspective-intent-state.js +5 -0
- package/dist/runtime/preview-transition-state.d.ts +142 -0
- package/dist/runtime/preview-transition-state.js +257 -0
- package/dist/runtime/render-mode-state.d.ts +51 -0
- package/dist/runtime/render-mode-state.js +76 -0
- package/dist/runtime/request-id.d.ts +4 -0
- package/dist/runtime/request-id.js +8 -0
- package/dist/runtime/resolve-schema-field.d.ts +99 -0
- package/dist/runtime/resolve-schema-field.js +356 -0
- package/dist/runtime/runtime-error-dispatch.d.ts +23 -0
- package/dist/runtime/runtime-error-dispatch.js +75 -0
- package/dist/runtime/schema-store.d.ts +121 -0
- package/dist/runtime/schema-store.js +667 -0
- package/dist/runtime/snapshot-assembly.d.ts +104 -0
- package/dist/runtime/snapshot-assembly.js +553 -0
- package/dist/runtime/snapshot-reporter.d.ts +55 -0
- package/dist/runtime/snapshot-reporter.js +375 -0
- package/dist/runtime/standalone-overlay-controller.d.ts +33 -0
- package/dist/runtime/standalone-overlay-controller.js +281 -0
- package/dist/runtime/store.d.ts +307 -0
- package/dist/runtime/store.js +1026 -0
- package/dist/runtime/windowed-minimap-projection.d.ts +28 -0
- package/dist/runtime/windowed-minimap-projection.js +189 -0
- package/dist/runtime/workspace-shortcuts.d.ts +30 -0
- package/dist/runtime/workspace-shortcuts.js +188 -0
- package/dist/sdk-error-copy.d.ts +48 -0
- package/dist/sdk-error-copy.js +182 -0
- package/dist/select-cursor.d.ts +2 -0
- package/dist/select-cursor.js +23 -0
- package/dist/server/admission-cookie.d.ts +38 -0
- package/dist/server/admission-cookie.js +122 -0
- package/dist/server/control-client.d.ts +29 -0
- package/dist/server/control-client.js +22 -0
- package/dist/server/control-errors.d.ts +22 -0
- package/dist/server/control-errors.js +165 -0
- package/dist/server/draft-route.d.ts +12 -0
- package/dist/server/draft-route.js +45 -0
- package/dist/server/extract-token.d.ts +30 -0
- package/dist/server/extract-token.js +98 -0
- package/dist/server/grant.d.ts +128 -0
- package/dist/server/grant.js +395 -0
- package/dist/server/ordinary-read-provenance.d.ts +11 -0
- package/dist/server/ordinary-read-provenance.js +124 -0
- package/dist/server/preview-read-metadata.d.ts +10 -0
- package/dist/server/preview-read-metadata.js +27 -0
- package/dist/server/provenance-frame-builder.d.ts +53 -0
- package/dist/server/provenance-frame-builder.js +102 -0
- package/dist/server/provenance-runtime.d.ts +70 -0
- package/dist/server/provenance-runtime.js +46 -0
- package/dist/server/read-context-command.d.ts +11 -0
- package/dist/server/read-context-command.js +53 -0
- package/dist/server/read-identity.d.ts +3 -0
- package/dist/server/read-identity.js +63 -0
- package/dist/server/read-lifecycle.d.ts +17 -0
- package/dist/server/read-lifecycle.js +52 -0
- package/dist/server/render-frame-provenance.d.ts +133 -0
- package/dist/server/render-frame-provenance.js +676 -0
- package/dist/server/render-id.d.ts +15 -0
- package/dist/server/render-id.js +22 -0
- package/dist/server/route-document-provenance.d.ts +77 -0
- package/dist/server/route-document-provenance.js +1327 -0
- package/dist/server/route-session-cookie.d.ts +33 -0
- package/dist/server/route-session-cookie.js +54 -0
- package/dist/session-projections.d.ts +33 -0
- package/dist/session-projections.js +43 -0
- package/dist/session-subscriber.d.ts +124 -0
- package/dist/session-subscriber.js +79 -0
- package/dist/sha256-fallback.d.ts +30 -0
- package/dist/sha256-fallback.js +126 -0
- package/dist/types.d.ts +446 -0
- package/dist/types.js +85 -0
- package/dist/validators.d.ts +61 -0
- package/dist/validators.js +110 -0
- package/package.json +206 -3
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Select-mode cursor for both Studio-owned capture layers and the
|
|
3
|
+
* customer iframe interaction policy.
|
|
4
|
+
*
|
|
5
|
+
* The compact, softened arrow geometry adapts Hugeicons' `Cursor 02` in its
|
|
6
|
+
* stroke-rounded style. It is scaled inside the cursor canvas so its generous
|
|
7
|
+
* white under-stroke stays visible without clipping. The composed black
|
|
8
|
+
* silhouette receives a soft, nearly centered shadow that follows the rounded
|
|
9
|
+
* artwork rather than mimicking the sharper macOS pointer.
|
|
10
|
+
*/
|
|
11
|
+
const PREVIEW_SELECT_CURSOR_PATH = 'M9.80282 4.62973 15.8364 6.99069C19.3164 8.35243 21.0564 9.03329 20.9987 10.1133 20.941 11.1934 19.1251 11.6886 15.4933 12.6791 14.412 12.974 13.8713 13.1215 13.4964 13.4963 13.1215 13.8712 12.9741 14.4119 12.6791 15.4933 11.6887 19.125 11.1934 20.9409 10.1134 20.9986 9.03335 21.0563 8.35249 19.3163 6.99075 15.8363L4.62979 9.80276C3.20411 6.15934 2.49127 4.33764 3.41448 3.41442 4.3377 2.49121 6.15941 3.20405 9.80282 4.62973Z';
|
|
12
|
+
const PREVIEW_SELECT_CURSOR_TRANSFORM = 'translate(0.75 0.75) scale(0.81)';
|
|
13
|
+
const PREVIEW_SELECT_CURSOR_SVG = [
|
|
14
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">',
|
|
15
|
+
'<defs><filter id="shadow" x="-4" y="-4" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feDropShadow dx="0" dy="0.25" stdDeviation="1.05" flood-color="#000" flood-opacity="0.22"/></filter></defs>',
|
|
16
|
+
'<g filter="url(#shadow)">',
|
|
17
|
+
`<path transform="${PREVIEW_SELECT_CURSOR_TRANSFORM}" d="${PREVIEW_SELECT_CURSOR_PATH}" fill="none" stroke="#fff" stroke-width="5" stroke-linejoin="round"/>`,
|
|
18
|
+
`<path transform="${PREVIEW_SELECT_CURSOR_TRANSFORM}" d="${PREVIEW_SELECT_CURSOR_PATH}" fill="#000" stroke="#000" stroke-width="1.25" stroke-linejoin="round"/>`,
|
|
19
|
+
'</g>',
|
|
20
|
+
'</svg>',
|
|
21
|
+
].join('');
|
|
22
|
+
/** CSS cursor value, including the tip-aligned hotspot and native fallback. */
|
|
23
|
+
export const PREVIEW_SELECT_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(PREVIEW_SELECT_CURSOR_SVG)}") 4 4, pointer`;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Server-only, authenticated browser admissions. HTTP arrival order is not authority. */
|
|
2
|
+
import { StorystickError } from '@storystick/client';
|
|
3
|
+
import { type PreviewGrantCookie } from './grant.js';
|
|
4
|
+
export interface PreviewCookieReader {
|
|
5
|
+
getAll(): Array<{
|
|
6
|
+
name: string;
|
|
7
|
+
value: string;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
export type AdmissionScope = {
|
|
11
|
+
projectId: string;
|
|
12
|
+
requestOrigin: string;
|
|
13
|
+
apiKey: string;
|
|
14
|
+
};
|
|
15
|
+
export type PreviewAdmission = {
|
|
16
|
+
sequence: number;
|
|
17
|
+
expiresAt: number;
|
|
18
|
+
} & ({
|
|
19
|
+
kind: 'active';
|
|
20
|
+
grant: PreviewGrantCookie;
|
|
21
|
+
} | {
|
|
22
|
+
kind: 'closed';
|
|
23
|
+
});
|
|
24
|
+
export declare const PREVIEW_CLOSURE_TTL_MS: number;
|
|
25
|
+
export declare function admissionCookieName(scope: AdmissionScope, admission: Pick<PreviewAdmission, 'kind' | 'sequence'>): Promise<string>;
|
|
26
|
+
export declare function admissionPresenceName(scope: AdmissionScope, admission: Pick<PreviewAdmission, 'kind' | 'sequence'>): Promise<string>;
|
|
27
|
+
export declare function encodePreviewAdmission(admission: PreviewAdmission, scope: AdmissionScope): Promise<string>;
|
|
28
|
+
export declare function admissionError(code: string, message: string): StorystickError;
|
|
29
|
+
export declare function admissionNames(reader: PreviewCookieReader, scope: AdmissionScope): Array<{
|
|
30
|
+
name: string;
|
|
31
|
+
sequence: number;
|
|
32
|
+
kind: 'active' | 'closed';
|
|
33
|
+
value: string;
|
|
34
|
+
}>;
|
|
35
|
+
export declare function readPreviewAdmission(reader: PreviewCookieReader, scope: AdmissionScope): Promise<PreviewAdmission | undefined>;
|
|
36
|
+
/** Only fresh authenticated activation may collect invalid names. The unguessable
|
|
37
|
+
* name tag prevents a deletion response from addressing a future valid cookie. */
|
|
38
|
+
export declare function invalidAdmissionNames(reader: PreviewCookieReader, scope: AdmissionScope): Promise<string[]>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** Server-only, authenticated browser admissions. HTTP arrival order is not authority. */
|
|
2
|
+
import { StorystickError } from '@storystick/client';
|
|
3
|
+
import { previewPresenceCookieName } from '@storystick/preview/identity/preview-cookie';
|
|
4
|
+
import { previewCapabilityCookieName } from './grant.js';
|
|
5
|
+
const MAX_COOKIE_BYTES = 3000;
|
|
6
|
+
// Also bounds parsing/HMAC work. Never silently choose an older admission on overflow.
|
|
7
|
+
const MAX_ADMISSION_COOKIES = 16;
|
|
8
|
+
export const PREVIEW_CLOSURE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
function base64(bytes) {
|
|
11
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
12
|
+
}
|
|
13
|
+
function bytes(value) {
|
|
14
|
+
return Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
|
|
15
|
+
}
|
|
16
|
+
async function key(scope) {
|
|
17
|
+
if (!scope.apiKey)
|
|
18
|
+
throw new Error('Preview admission requires a server API key');
|
|
19
|
+
// Domain separation from every other use of the site's server credential.
|
|
20
|
+
const material = await crypto.subtle.digest('SHA-256', encoder.encode(`storystick:preview-admission:v1\0${scope.apiKey}`));
|
|
21
|
+
return crypto.subtle.importKey('raw', material, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
|
22
|
+
}
|
|
23
|
+
async function suffix(scope, admission) {
|
|
24
|
+
// An observed malformed name cannot coincide with a future valid write.
|
|
25
|
+
const tag = base64(new Uint8Array(await crypto.subtle.sign('HMAC', await key(scope), encoder.encode(`name:${admission.sequence}`)))).slice(0, 22);
|
|
26
|
+
return `_${admission.kind === 'active' ? 'a' : 'x'}${admission.sequence}_${tag}`;
|
|
27
|
+
}
|
|
28
|
+
export async function admissionCookieName(scope, admission) {
|
|
29
|
+
return previewCapabilityCookieName(scope) + await suffix(scope, admission);
|
|
30
|
+
}
|
|
31
|
+
export async function admissionPresenceName(scope, admission) {
|
|
32
|
+
return previewPresenceCookieName(scope) + await suffix(scope, admission);
|
|
33
|
+
}
|
|
34
|
+
export async function encodePreviewAdmission(admission, scope) {
|
|
35
|
+
// Do not persist the short-lived access JWT. Exchanges are cached server-side.
|
|
36
|
+
const payload = [1, admission.kind, admission.sequence, admission.expiresAt, scope.projectId, scope.requestOrigin,
|
|
37
|
+
...(admission.kind === 'active' ? [admission.grant.grantId, admission.grant.grantToken,
|
|
38
|
+
admission.grant.contentShapeCapability, admission.grant.applicationBuildId ?? null] : [])];
|
|
39
|
+
const encoded = base64(encoder.encode(JSON.stringify(payload)));
|
|
40
|
+
const signature = base64(new Uint8Array(await crypto.subtle.sign('HMAC', await key(scope), encoder.encode(encoded))));
|
|
41
|
+
const result = `${encoded}.${signature}`;
|
|
42
|
+
if (result.length > MAX_COOKIE_BYTES)
|
|
43
|
+
throw admissionError('PREVIEW_ADMISSION_LIMIT', 'This Preview opening exceeds the browser cookie size limit.');
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
async function decode(value, scope) {
|
|
47
|
+
try {
|
|
48
|
+
if (value.length > MAX_COOKIE_BYTES)
|
|
49
|
+
throw new Error();
|
|
50
|
+
const [payload, signature, extra] = value.split('.');
|
|
51
|
+
if (!payload || !signature || extra !== undefined || !await crypto.subtle.verify('HMAC', await key(scope), bytes(signature), encoder.encode(payload)))
|
|
52
|
+
throw new Error();
|
|
53
|
+
const data = JSON.parse(new TextDecoder().decode(bytes(payload)));
|
|
54
|
+
if (!Array.isArray(data) || data[0] !== 1 || !Number.isSafeInteger(data[2]) || data[2] <= 0 ||
|
|
55
|
+
!Number.isSafeInteger(data[3]) || data[3] <= 0 || data[4] !== scope.projectId || data[5] !== scope.requestOrigin)
|
|
56
|
+
throw new Error();
|
|
57
|
+
const base = { sequence: data[2], expiresAt: data[3] };
|
|
58
|
+
if (data[1] === 'closed' && data.length === 6)
|
|
59
|
+
return { ...base, kind: 'closed' };
|
|
60
|
+
if (data[1] !== 'active' || data.length !== 10 || ![data[6], data[7], data[8]].every(v => typeof v === 'string' && v.length > 0) ||
|
|
61
|
+
!(data[9] === null || typeof data[9] === 'string'))
|
|
62
|
+
throw new Error();
|
|
63
|
+
return { ...base, kind: 'active', grant: {
|
|
64
|
+
v: 2, grantId: data[6], grantToken: data[7], grantExpiresAt: base.expiresAt,
|
|
65
|
+
projectId: scope.projectId, previewOrigin: scope.requestOrigin,
|
|
66
|
+
contentShapeCapability: data[8], ...(data[9] === null ? {} : { applicationBuildId: data[9] }),
|
|
67
|
+
accessToken: '', accessExpiresAt: 0,
|
|
68
|
+
} };
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw admissionError('PREVIEW_GRANT_MALFORMED', 'Preview admission is invalid. Open Preview from Studio again.');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function admissionError(code, message) {
|
|
75
|
+
return new StorystickError(message, { code: `storystick/${code}`, category: 'auth', recoverable: true, retryable: false });
|
|
76
|
+
}
|
|
77
|
+
export function admissionNames(reader, scope) {
|
|
78
|
+
const prefix = previewCapabilityCookieName(scope);
|
|
79
|
+
return reader.getAll().flatMap(({ name, value }) => {
|
|
80
|
+
if (!name.startsWith(prefix + '_'))
|
|
81
|
+
return [];
|
|
82
|
+
const match = /^_([ax])([1-9]\d*)_([A-Za-z0-9_-]{22})$/.exec(name.slice(prefix.length));
|
|
83
|
+
const sequence = Number(match?.[2]);
|
|
84
|
+
if (!match || !Number.isSafeInteger(sequence))
|
|
85
|
+
throw admissionError('PREVIEW_GRANT_MALFORMED', 'Invalid Preview admission name.');
|
|
86
|
+
return [{ name, value, sequence, kind: match[1] === 'a' ? 'active' : 'closed' }];
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
export async function readPreviewAdmission(reader, scope) {
|
|
90
|
+
if (!scope.requestOrigin)
|
|
91
|
+
throw admissionError('PREVIEW_ORIGIN_MISMATCH', 'Preview requires a verified request origin.');
|
|
92
|
+
const candidates = admissionNames(reader, scope);
|
|
93
|
+
if (candidates.length > MAX_ADMISSION_COOKIES)
|
|
94
|
+
throw admissionError('PREVIEW_ADMISSION_LIMIT', 'Too many Preview openings. Open Preview from Studio again.');
|
|
95
|
+
candidates.sort((a, b) => b.sequence - a.sequence || Number(b.kind === 'closed') - Number(a.kind === 'closed'));
|
|
96
|
+
const latest = candidates[0];
|
|
97
|
+
if (!latest)
|
|
98
|
+
return undefined;
|
|
99
|
+
// Authenticate the winner, never filter invalid/expired/closed winners then fall back.
|
|
100
|
+
const admission = await decode(latest.value, scope);
|
|
101
|
+
if (admission.sequence !== latest.sequence || admission.kind !== latest.kind || latest.name !== await admissionCookieName(scope, admission))
|
|
102
|
+
throw admissionError('PREVIEW_GRANT_MALFORMED', 'Preview admission binding is invalid.');
|
|
103
|
+
return admission;
|
|
104
|
+
}
|
|
105
|
+
/** Only fresh authenticated activation may collect invalid names. The unguessable
|
|
106
|
+
* name tag prevents a deletion response from addressing a future valid cookie. */
|
|
107
|
+
export async function invalidAdmissionNames(reader, scope) {
|
|
108
|
+
const prefix = previewCapabilityCookieName(scope);
|
|
109
|
+
const invalid = [];
|
|
110
|
+
for (const cookie of reader.getAll()) {
|
|
111
|
+
if (!cookie.name.startsWith(prefix + '_'))
|
|
112
|
+
continue;
|
|
113
|
+
try {
|
|
114
|
+
const admission = await decode(cookie.value, scope);
|
|
115
|
+
if (cookie.name === await admissionCookieName(scope, admission))
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
catch { /* no authority */ }
|
|
119
|
+
invalid.push(cookie.name);
|
|
120
|
+
}
|
|
121
|
+
return invalid;
|
|
122
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Server-side Preview control uses public Storystick HTTP, never Convex RPC. */
|
|
2
|
+
import type { PreviewPerspective } from '@storystick/types/preview-query-targeting';
|
|
3
|
+
export declare function previewControlClient(endpoint: string, token: string): {
|
|
4
|
+
context: <T = unknown>(input?: {
|
|
5
|
+
sessionId?: string;
|
|
6
|
+
includeProjectionCheckpoint?: boolean;
|
|
7
|
+
includeRouting?: boolean;
|
|
8
|
+
}) => Promise<T>;
|
|
9
|
+
session: (input: {
|
|
10
|
+
sessionId: string;
|
|
11
|
+
mode?: "routing-only";
|
|
12
|
+
}) => Promise<unknown>;
|
|
13
|
+
draft: (input: {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
draftDocumentId?: string;
|
|
16
|
+
locale?: string;
|
|
17
|
+
contextId: string;
|
|
18
|
+
revision: number;
|
|
19
|
+
fallbackPolicy?: "none" | "explicit";
|
|
20
|
+
}) => Promise<unknown>;
|
|
21
|
+
changeContext: (input: {
|
|
22
|
+
commandId: string;
|
|
23
|
+
expectedContextId: string;
|
|
24
|
+
expectedRevision: number;
|
|
25
|
+
perspective: PreviewPerspective | null;
|
|
26
|
+
}) => Promise<{
|
|
27
|
+
status: "applied" | "conflict";
|
|
28
|
+
}>;
|
|
29
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function previewControlClient(endpoint, token) {
|
|
2
|
+
async function call(operation, input) {
|
|
3
|
+
const response = await fetch(`${endpoint.replace(/\/$/, '')}/api/preview/control`, {
|
|
4
|
+
method: 'POST', cache: 'no-store', redirect: 'error',
|
|
5
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
6
|
+
body: JSON.stringify({ operation, input }),
|
|
7
|
+
});
|
|
8
|
+
const body = await response.json();
|
|
9
|
+
if (!response.ok) {
|
|
10
|
+
const code = typeof body?.error?.code === 'string' ? body.error.code : 'PREVIEW_CONTROL_UNAVAILABLE';
|
|
11
|
+
// Keep the native preview error carrier consumed by mapPreviewSessionError.
|
|
12
|
+
throw Object.assign(new Error(code), { data: { code } });
|
|
13
|
+
}
|
|
14
|
+
return body;
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
context: (input = {}) => call('context', input),
|
|
18
|
+
session: (input) => call('session', input),
|
|
19
|
+
draft: (input) => call('draft', input),
|
|
20
|
+
changeContext: (input) => call('change-context', input),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PreviewAdapterError } from "@storystick/preview/server/grant";
|
|
2
|
+
export type PreviewRouteErrorCategory = "system" | "auth" | "validation" | "conflict" | "cancel";
|
|
3
|
+
export type PreviewRouteError = {
|
|
4
|
+
status: number;
|
|
5
|
+
code: string;
|
|
6
|
+
message: string;
|
|
7
|
+
category: PreviewRouteErrorCategory;
|
|
8
|
+
recoverable: boolean;
|
|
9
|
+
retryable: boolean;
|
|
10
|
+
};
|
|
11
|
+
type ResponseError = PreviewAdapterError | PreviewRouteError;
|
|
12
|
+
export declare function previewInitialRouteErrorResponse(request: Request, error: ResponseError): Response;
|
|
13
|
+
export declare function previewErrorResponse(error: PreviewAdapterError): Response;
|
|
14
|
+
export declare function previewErrorResponse(error: PreviewRouteError): Response;
|
|
15
|
+
export declare function previewErrorResponse(status: number, code: string, message: string, opts?: {
|
|
16
|
+
category?: PreviewRouteErrorCategory;
|
|
17
|
+
recoverable?: boolean;
|
|
18
|
+
retryable?: boolean;
|
|
19
|
+
}): Response;
|
|
20
|
+
/** Preserve typed authorization failures; never expose upstream exception text. */
|
|
21
|
+
export declare function previewControlErrorResponse(error: unknown): Response;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { StorystickError } from "@storystick/client";
|
|
2
|
+
import { mapPreviewSessionError } from "@storystick/preview/session-subscriber";
|
|
3
|
+
export function previewInitialRouteErrorResponse(request, error) {
|
|
4
|
+
if (error.recoverable && requestAcceptsHtml(request)) {
|
|
5
|
+
return previewHtmlErrorResponse(error, new URL(request.url).searchParams.get("_ss_session"));
|
|
6
|
+
}
|
|
7
|
+
return previewErrorResponse(error);
|
|
8
|
+
}
|
|
9
|
+
export function previewErrorResponse(statusOrError, code, message, opts = {}) {
|
|
10
|
+
if (typeof statusOrError !== "number") {
|
|
11
|
+
return Response.json({
|
|
12
|
+
error: {
|
|
13
|
+
code: statusOrError.code,
|
|
14
|
+
message: statusOrError.message,
|
|
15
|
+
category: statusOrError.category,
|
|
16
|
+
recoverable: statusOrError.recoverable,
|
|
17
|
+
retryable: statusOrError.retryable,
|
|
18
|
+
...('upstreamCode' in statusOrError && { upstreamCode: statusOrError.upstreamCode }),
|
|
19
|
+
...('upstreamRef' in statusOrError && { upstreamRef: statusOrError.upstreamRef }),
|
|
20
|
+
},
|
|
21
|
+
}, {
|
|
22
|
+
status: statusOrError.status,
|
|
23
|
+
headers: { "Cache-Control": "no-store" },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return Response.json({
|
|
27
|
+
error: {
|
|
28
|
+
code: code,
|
|
29
|
+
message: message,
|
|
30
|
+
category: opts.category ?? "system",
|
|
31
|
+
recoverable: opts.recoverable ?? false,
|
|
32
|
+
retryable: opts.retryable ?? false,
|
|
33
|
+
},
|
|
34
|
+
}, {
|
|
35
|
+
status: statusOrError,
|
|
36
|
+
headers: { "Cache-Control": "no-store" },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function requestAcceptsHtml(request) {
|
|
40
|
+
const accept = request.headers.get("accept");
|
|
41
|
+
if (accept === null)
|
|
42
|
+
return false;
|
|
43
|
+
return accept
|
|
44
|
+
.split(",")
|
|
45
|
+
.map((part) => part.trim().toLowerCase().split(";", 1)[0])
|
|
46
|
+
.includes("text/html");
|
|
47
|
+
}
|
|
48
|
+
function previewHtmlErrorResponse(error, sessionId) {
|
|
49
|
+
return new Response(buildHtmlError(error, sessionId), {
|
|
50
|
+
status: error.status,
|
|
51
|
+
headers: {
|
|
52
|
+
"Cache-Control": "no-store",
|
|
53
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function buildHtmlError(error, sessionId) {
|
|
58
|
+
// Only structured, known activation failures cross this pre-runtime seam.
|
|
59
|
+
// The parent accepts them only from its addressed frame, origin and session.
|
|
60
|
+
const diagnosticCodes = ['PREVIEW_PROJECT_MISMATCH', 'PREVIEW_ACTIVATION_INVALID',
|
|
61
|
+
'PREVIEW_GRANT_EXPIRED', 'PREVIEW_GRANT_REVOKED', 'PREVIEW_ACCESS_REVOKED', 'PREVIEW_GRANT_FORBIDDEN'];
|
|
62
|
+
const diagnostic = sessionId && /^[A-Za-z0-9_-]{1,128}$/.test(sessionId) && diagnosticCodes.includes(error.code)
|
|
63
|
+
? `<script>window.parent.postMessage(${JSON.stringify({ type: 'storystick.preview.activation-error', sessionId, code: error.code })}, '*');</script>`
|
|
64
|
+
: '';
|
|
65
|
+
const code = escapeHtml(error.code);
|
|
66
|
+
const message = escapeHtml(error.message);
|
|
67
|
+
const retryScript = error.retryable
|
|
68
|
+
? `<script>
|
|
69
|
+
(() => {
|
|
70
|
+
const maxAttempts = 12;
|
|
71
|
+
let attempt = 0;
|
|
72
|
+
const retry = async () => {
|
|
73
|
+
attempt += 1;
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetch(window.location.href, {
|
|
76
|
+
cache: 'no-store',
|
|
77
|
+
redirect: 'follow',
|
|
78
|
+
headers: { Accept: 'application/json' },
|
|
79
|
+
});
|
|
80
|
+
if (response.redirected || (response.ok && response.url !== window.location.href)) {
|
|
81
|
+
window.location.replace(response.url);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (response.ok) {
|
|
85
|
+
window.location.reload();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
} catch (_) {}
|
|
89
|
+
if (attempt >= maxAttempts) {
|
|
90
|
+
document.body.dataset.previewRecovery = 'stopped';
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const delay = Math.min(5000, Math.round(500 * Math.pow(1.5, attempt - 1)));
|
|
94
|
+
window.setTimeout(retry, delay);
|
|
95
|
+
};
|
|
96
|
+
window.setTimeout(retry, 500);
|
|
97
|
+
})();
|
|
98
|
+
</script>`
|
|
99
|
+
: "";
|
|
100
|
+
const title = error.retryable ? "Preview recovering" : "Preview unavailable";
|
|
101
|
+
const heading = error.retryable
|
|
102
|
+
? "Preview reconnecting"
|
|
103
|
+
: error.code === "PREVIEW_PROJECT_MISMATCH" ? "Preview is connected to another project" : "Reopen this preview from Storystick Studio";
|
|
104
|
+
return `<!doctype html>
|
|
105
|
+
<html lang="en">
|
|
106
|
+
<head>
|
|
107
|
+
<meta charset="utf-8">
|
|
108
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
109
|
+
<title>${title}</title>
|
|
110
|
+
<style>
|
|
111
|
+
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
|
112
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: Canvas; color: CanvasText; }
|
|
113
|
+
main { max-width: 28rem; padding: 1.5rem; line-height: 1.5; }
|
|
114
|
+
h1 { font-size: 1rem; margin: 0 0 .5rem; }
|
|
115
|
+
p { margin: 0; color: color-mix(in srgb, CanvasText 70%, transparent); }
|
|
116
|
+
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .8125rem; }
|
|
117
|
+
</style>
|
|
118
|
+
</head>
|
|
119
|
+
<body>
|
|
120
|
+
<main aria-live="polite">
|
|
121
|
+
<h1>${heading}</h1>
|
|
122
|
+
<p data-preview-error="${code}">${message}</p>
|
|
123
|
+
</main>
|
|
124
|
+
${diagnostic}
|
|
125
|
+
${retryScript}
|
|
126
|
+
</body>
|
|
127
|
+
</html>`;
|
|
128
|
+
}
|
|
129
|
+
function escapeHtml(value) {
|
|
130
|
+
return value.replace(/[&<>"']/g, (char) => {
|
|
131
|
+
switch (char) {
|
|
132
|
+
case "&":
|
|
133
|
+
return "&";
|
|
134
|
+
case "<":
|
|
135
|
+
return "<";
|
|
136
|
+
case ">":
|
|
137
|
+
return ">";
|
|
138
|
+
case '"':
|
|
139
|
+
return """;
|
|
140
|
+
case "'":
|
|
141
|
+
return "'";
|
|
142
|
+
default:
|
|
143
|
+
return char;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** Preserve typed authorization failures; never expose upstream exception text. */
|
|
148
|
+
export function previewControlErrorResponse(error) {
|
|
149
|
+
if (error instanceof StorystickError) {
|
|
150
|
+
return previewErrorResponse(error.category === 'auth' ? 401 : error.category === 'conflict' ? 409 : error.retryable ? 503 : 502, error.code?.replace(/^storystick\//, '') ?? 'PREVIEW_CONTEXT_UNAVAILABLE', 'Preview access is unavailable.', {
|
|
151
|
+
category: error.category, recoverable: error.recoverable, retryable: error.retryable,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const mapped = mapPreviewSessionError(error);
|
|
155
|
+
if (mapped.code === 'PREVIEW_ADMISSION_SUPERSEDED')
|
|
156
|
+
return previewErrorResponse(409, mapped.code, 'Preview changed. Reconnecting to the installed admission.', { category: 'conflict', recoverable: true, retryable: true });
|
|
157
|
+
if (mapped.code !== 'UNKNOWN') {
|
|
158
|
+
return previewErrorResponse(401, mapped.code, 'Preview access is no longer available.', {
|
|
159
|
+
category: 'auth', recoverable: true, retryable: false,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return previewErrorResponse(503, 'PREVIEW_CONTEXT_UNAVAILABLE', 'Preview is temporarily unavailable.', {
|
|
163
|
+
category: 'system', recoverable: true, retryable: true,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type RequestLocaleContext, type LocaleRoutingSettings } from '@storystick/client';
|
|
2
|
+
export declare function pathnameForPreviewDraft(document: {
|
|
3
|
+
readonly resolvedPathname?: unknown;
|
|
4
|
+
readonly fields?: unknown;
|
|
5
|
+
}): string;
|
|
6
|
+
export declare function previewDocumentSeo(document: {
|
|
7
|
+
readonly fields?: unknown;
|
|
8
|
+
}): {
|
|
9
|
+
indexable: boolean;
|
|
10
|
+
canonicalUrl?: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function localeContextForPreviewDraft(pathname: string | readonly string[] | undefined, settings: LocaleRoutingSettings): RequestLocaleContext | null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { normalizePathname, resolveLocalePathname } from '@storystick/client';
|
|
2
|
+
function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); }
|
|
3
|
+
export function pathnameForPreviewDraft(document) {
|
|
4
|
+
const fields = isRecord(document.fields) ? document.fields : {};
|
|
5
|
+
const resolved = typeof document.resolvedPathname === 'string'
|
|
6
|
+
? document.resolvedPathname
|
|
7
|
+
: typeof fields.resolvedPathname === 'string'
|
|
8
|
+
? fields.resolvedPathname
|
|
9
|
+
: typeof fields.pathname === 'string'
|
|
10
|
+
? fields.pathname
|
|
11
|
+
: '';
|
|
12
|
+
return normalizePathname(resolved);
|
|
13
|
+
}
|
|
14
|
+
export function previewDocumentSeo(document) {
|
|
15
|
+
const fields = isRecord(document.fields) ? document.fields : {};
|
|
16
|
+
const indexable = fields.noIndex !== true;
|
|
17
|
+
if (typeof fields.canonicalUrl !== 'string')
|
|
18
|
+
return { indexable };
|
|
19
|
+
try {
|
|
20
|
+
const canonicalUrl = new URL(fields.canonicalUrl.trim());
|
|
21
|
+
if ((canonicalUrl.protocol === 'http:' || canonicalUrl.protocol === 'https:') &&
|
|
22
|
+
canonicalUrl.username === '' &&
|
|
23
|
+
canonicalUrl.password === '') {
|
|
24
|
+
return { indexable, canonicalUrl: canonicalUrl.toString() };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Draft metadata stays readable; strict publication returns a typed error.
|
|
29
|
+
}
|
|
30
|
+
return { indexable };
|
|
31
|
+
}
|
|
32
|
+
export function localeContextForPreviewDraft(pathname, settings) {
|
|
33
|
+
const route = resolveLocalePathname(pathname, settings);
|
|
34
|
+
if (!route.ok)
|
|
35
|
+
return null;
|
|
36
|
+
return {
|
|
37
|
+
locale: route.locale,
|
|
38
|
+
defaultLocale: settings.defaultLocale,
|
|
39
|
+
pathname: route.pathname,
|
|
40
|
+
publicPathname: route.publicPathname,
|
|
41
|
+
locales: settings.locales,
|
|
42
|
+
localeRouting: settings.localeRouting,
|
|
43
|
+
synthetic: settings.synthetic === true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type PreviewGrantCookie } from './grant.js';
|
|
2
|
+
import { type AdmissionScope, type PreviewCookieReader } from './admission-cookie.js';
|
|
3
|
+
export type CookieStoreLike = PreviewCookieReader;
|
|
4
|
+
export interface ResolvePreviewGrantAccessOptions extends AdmissionScope {
|
|
5
|
+
endpoint: string;
|
|
6
|
+
applicationBuildId?: string;
|
|
7
|
+
now?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface PreviewGrantAccessFields {
|
|
10
|
+
token: string;
|
|
11
|
+
tokenExpiresAt: number;
|
|
12
|
+
projectId: string;
|
|
13
|
+
previewOrigin: string;
|
|
14
|
+
grantId: string;
|
|
15
|
+
grantExpiresAt: number;
|
|
16
|
+
admissionSequence: number;
|
|
17
|
+
/** Server-only handles. Never serialize these into browser responses. */
|
|
18
|
+
grantToken: string;
|
|
19
|
+
contentShapeCapability: string;
|
|
20
|
+
}
|
|
21
|
+
type ReadyGrant = PreviewGrantCookie & {
|
|
22
|
+
contentShapeCapability: string;
|
|
23
|
+
admissionSequence: number;
|
|
24
|
+
};
|
|
25
|
+
export declare function readPreviewGrantCookie(cookieStore: CookieStoreLike, scope: AdmissionScope, now?: number): Promise<ReadyGrant | null | undefined>;
|
|
26
|
+
export declare function primePreviewGrantAccess(grant: ReadyGrant, options: ResolvePreviewGrantAccessOptions): void;
|
|
27
|
+
export declare function clearPreviewGrantAccessCache(): void;
|
|
28
|
+
/** null is an explicitly closed admission, distinct from a missing credential. */
|
|
29
|
+
export declare function resolvePreviewGrantAccess(cookieStore: CookieStoreLike, options: ResolvePreviewGrantAccessOptions): Promise<PreviewGrantAccessFields | null | undefined>;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { StorystickError } from '@storystick/client';
|
|
2
|
+
import { PreviewGrantExchangeError, classifyPreviewGrantError, exchangePreviewGrantForServer, } from './grant.js';
|
|
3
|
+
import { readPreviewAdmission, admissionError } from './admission-cookie.js';
|
|
4
|
+
export async function readPreviewGrantCookie(cookieStore, scope, now = Date.now()) {
|
|
5
|
+
const admission = await readPreviewAdmission(cookieStore, scope);
|
|
6
|
+
if (!admission)
|
|
7
|
+
return undefined;
|
|
8
|
+
if (admission.kind === 'closed')
|
|
9
|
+
return null;
|
|
10
|
+
if (admission.expiresAt <= now)
|
|
11
|
+
throw admissionError('PREVIEW_GRANT_EXPIRED', 'Preview grant expired. Open Preview from Studio again.');
|
|
12
|
+
return { ...admission.grant, contentShapeCapability: admission.grant.contentShapeCapability, admissionSequence: admission.sequence };
|
|
13
|
+
}
|
|
14
|
+
// A cache of short-lived credentials, not permissions: every downstream read
|
|
15
|
+
// still validates the grant and human session. Keys include ALL server bindings.
|
|
16
|
+
const accessCache = new Map();
|
|
17
|
+
const exchanges = new Map();
|
|
18
|
+
const MAX_ACCESS_CACHE = 64;
|
|
19
|
+
function cacheKey(grant, options) {
|
|
20
|
+
return JSON.stringify([options.endpoint, options.apiKey, options.projectId, options.requestOrigin,
|
|
21
|
+
options.applicationBuildId, grant.admissionSequence, grant.grantToken, grant.contentShapeCapability]);
|
|
22
|
+
}
|
|
23
|
+
function remember(key, value) {
|
|
24
|
+
accessCache.delete(key);
|
|
25
|
+
accessCache.set(key, value);
|
|
26
|
+
while (accessCache.size > MAX_ACCESS_CACHE)
|
|
27
|
+
accessCache.delete(accessCache.keys().next().value);
|
|
28
|
+
}
|
|
29
|
+
export function primePreviewGrantAccess(grant, options) {
|
|
30
|
+
remember(cacheKey(grant, options), {
|
|
31
|
+
token: grant.accessToken, tokenExpiresAt: grant.accessExpiresAt,
|
|
32
|
+
grantId: grant.grantId, grantExpiresAt: grant.grantExpiresAt, grantToken: grant.grantToken,
|
|
33
|
+
projectId: grant.projectId, previewOrigin: grant.previewOrigin,
|
|
34
|
+
admissionSequence: grant.admissionSequence, contentShapeCapability: grant.contentShapeCapability,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export function clearPreviewGrantAccessCache() { accessCache.clear(); }
|
|
38
|
+
/** null is an explicitly closed admission, distinct from a missing credential. */
|
|
39
|
+
export async function resolvePreviewGrantAccess(cookieStore, options) {
|
|
40
|
+
const now = options.now ?? Date.now();
|
|
41
|
+
const grant = await readPreviewGrantCookie(cookieStore, options, now);
|
|
42
|
+
if (!grant)
|
|
43
|
+
return grant;
|
|
44
|
+
if (grant.applicationBuildId !== options.applicationBuildId) {
|
|
45
|
+
throw new StorystickError('Open Preview again from Studio after the application build changed.', {
|
|
46
|
+
code: 'storystick/CONTENT_SHAPE_RELOAD_REQUIRED', category: 'conflict', recoverable: true, retryable: false,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const key = cacheKey(grant, options);
|
|
50
|
+
const cached = accessCache.get(key);
|
|
51
|
+
if (cached && cached.tokenExpiresAt - 30_000 > now)
|
|
52
|
+
return cached;
|
|
53
|
+
accessCache.delete(key);
|
|
54
|
+
const pending = exchanges.get(key);
|
|
55
|
+
if (pending)
|
|
56
|
+
return pending;
|
|
57
|
+
const flight = exchangeGrant(grant, options, key, now);
|
|
58
|
+
// Bound shared bookkeeping; overflowing independent requests still authorize.
|
|
59
|
+
if (exchanges.size < MAX_ACCESS_CACHE)
|
|
60
|
+
exchanges.set(key, flight);
|
|
61
|
+
try {
|
|
62
|
+
return await flight;
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
if (exchanges.get(key) === flight)
|
|
66
|
+
exchanges.delete(key);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function exchangeGrant(grant, options, key, now) {
|
|
70
|
+
try {
|
|
71
|
+
const exchange = await exchangePreviewGrantForServer({
|
|
72
|
+
endpoint: options.endpoint, apiKey: options.apiKey, applicationBuildId: options.applicationBuildId,
|
|
73
|
+
grantToken: grant.grantToken, requestOrigin: options.requestOrigin, contentShapeCapability: grant.contentShapeCapability,
|
|
74
|
+
});
|
|
75
|
+
if (exchange.projectId !== grant.projectId || exchange.previewOrigin !== grant.previewOrigin ||
|
|
76
|
+
exchange.grantId !== grant.grantId || exchange.admissionSequence !== grant.admissionSequence ||
|
|
77
|
+
exchange.grantExpiresAt !== grant.grantExpiresAt) {
|
|
78
|
+
throw admissionError('PREVIEW_GRANT_BINDING_MISMATCH', 'Preview exchange returned incompatible bindings.');
|
|
79
|
+
}
|
|
80
|
+
const fields = {
|
|
81
|
+
token: exchange.token, tokenExpiresAt: now + exchange.tokenExpiresIn * 1_000,
|
|
82
|
+
projectId: grant.projectId, previewOrigin: grant.previewOrigin, grantId: grant.grantId,
|
|
83
|
+
grantExpiresAt: grant.grantExpiresAt, grantToken: grant.grantToken,
|
|
84
|
+
admissionSequence: grant.admissionSequence, contentShapeCapability: grant.contentShapeCapability,
|
|
85
|
+
};
|
|
86
|
+
remember(key, fields);
|
|
87
|
+
return fields;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (!(error instanceof PreviewGrantExchangeError))
|
|
91
|
+
throw error;
|
|
92
|
+
const publicError = classifyPreviewGrantError(error, 'exchange');
|
|
93
|
+
throw new StorystickError(`${publicError.code} — ${publicError.message}`, {
|
|
94
|
+
code: `storystick/${publicError.code}`, ref: publicError.upstreamRef,
|
|
95
|
+
category: publicError.category, recoverable: publicError.recoverable, retryable: publicError.retryable,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|