@storystick/preview 0.0.0-stage → 0.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/LICENSE +112 -0
  2. package/PUBLIC-API.md +56 -0
  3. package/README.md +42 -2
  4. package/dist/.source-hash +1 -0
  5. package/dist/authoring/runtime-contract.d.ts +92 -0
  6. package/dist/authoring/runtime-contract.js +28 -0
  7. package/dist/channel-taxonomy.d.ts +17 -0
  8. package/dist/channel-taxonomy.js +46 -0
  9. package/dist/context-command.d.ts +18 -0
  10. package/dist/context-command.js +38 -0
  11. package/dist/dev/suspicious-codeword-detector.d.ts +15 -0
  12. package/dist/dev/suspicious-codeword-detector.js +220 -0
  13. package/dist/dev/suspicious-codeword-detector.noop.d.ts +3 -0
  14. package/dist/dev/suspicious-codeword-detector.noop.js +4 -0
  15. package/dist/framework/browser-context.d.ts +2 -0
  16. package/dist/framework/browser-context.js +2 -0
  17. package/dist/framework/generation-refresh.d.ts +23 -0
  18. package/dist/framework/generation-refresh.js +82 -0
  19. package/dist/framework/generation-signal.d.ts +8 -0
  20. package/dist/framework/generation-signal.js +17 -0
  21. package/dist/framework/projection-generation-watch.d.ts +5 -0
  22. package/dist/framework/projection-generation-watch.js +53 -0
  23. package/dist/framework/render-receipt.d.ts +8 -0
  24. package/dist/framework/render-receipt.js +10 -0
  25. package/dist/framework/runtime-adapter-store.d.ts +30 -0
  26. package/dist/framework/runtime-adapter-store.js +81 -0
  27. package/dist/grant-coordinator.d.ts +53 -0
  28. package/dist/grant-coordinator.js +329 -0
  29. package/dist/grant-lifecycle.d.ts +52 -0
  30. package/dist/grant-lifecycle.js +74 -0
  31. package/dist/identity/__test-adapter.d.ts +34 -0
  32. package/dist/identity/__test-adapter.js +59 -0
  33. package/dist/identity/browser-context-gate.d.ts +4 -0
  34. package/dist/identity/browser-context-gate.js +22 -0
  35. package/dist/identity/browser-context.d.ts +13 -0
  36. package/dist/identity/browser-context.js +22 -0
  37. package/dist/identity/carriers/postmessage.d.ts +84 -0
  38. package/dist/identity/carriers/postmessage.js +177 -0
  39. package/dist/identity/carriers/storage-state.d.ts +41 -0
  40. package/dist/identity/carriers/storage-state.js +99 -0
  41. package/dist/identity/carriers/storage.d.ts +39 -0
  42. package/dist/identity/carriers/storage.js +182 -0
  43. package/dist/identity/carriers/url.d.ts +19 -0
  44. package/dist/identity/carriers/url.js +38 -0
  45. package/dist/identity/identity-state-machine.d.ts +70 -0
  46. package/dist/identity/identity-state-machine.js +244 -0
  47. package/dist/identity/identity-types.d.ts +192 -0
  48. package/dist/identity/identity-types.js +22 -0
  49. package/dist/identity/index.d.ts +18 -0
  50. package/dist/identity/index.js +16 -0
  51. package/dist/identity/preview-cookie.d.ts +9 -0
  52. package/dist/identity/preview-cookie.js +12 -0
  53. package/dist/identity/preview-draft.d.ts +6 -0
  54. package/dist/identity/preview-draft.js +25 -0
  55. package/dist/identity/preview-identity.d.ts +28 -0
  56. package/dist/identity/preview-identity.js +84 -0
  57. package/dist/identity/preview-scope.d.ts +28 -0
  58. package/dist/identity/preview-scope.js +47 -0
  59. package/dist/identity/resolver.d.ts +110 -0
  60. package/dist/identity/resolver.js +334 -0
  61. package/dist/identity/session-query.d.ts +4 -0
  62. package/dist/identity/session-query.js +19 -0
  63. package/dist/index.d.ts +90 -0
  64. package/dist/index.js +788 -0
  65. package/dist/interaction-mode.d.ts +58 -0
  66. package/dist/interaction-mode.js +93 -0
  67. package/dist/nav-handler.d.ts +95 -0
  68. package/dist/nav-handler.js +84 -0
  69. package/dist/overlay-label.d.ts +79 -0
  70. package/dist/overlay-label.js +73 -0
  71. package/dist/page-presentation.d.ts +24 -0
  72. package/dist/page-presentation.js +32 -0
  73. package/dist/provenance/anchor-map.d.ts +95 -0
  74. package/dist/provenance/anchor-map.js +177 -0
  75. package/dist/provenance/carrier-plan.d.ts +51 -0
  76. package/dist/provenance/carrier-plan.js +90 -0
  77. package/dist/provenance/coded-text.d.ts +20 -0
  78. package/dist/provenance/coded-text.js +302 -0
  79. package/dist/provenance/direct-edit-locator.d.ts +29 -0
  80. package/dist/provenance/direct-edit-locator.js +218 -0
  81. package/dist/provenance/editable-leaf.d.ts +134 -0
  82. package/dist/provenance/editable-leaf.js +483 -0
  83. package/dist/provenance/editor-target.d.ts +29 -0
  84. package/dist/provenance/editor-target.js +46 -0
  85. package/dist/provenance/field-leaf-walker.d.ts +21 -0
  86. package/dist/provenance/field-leaf-walker.js +98 -0
  87. package/dist/provenance/frame-dependencies.d.ts +212 -0
  88. package/dist/provenance/frame-dependencies.js +550 -0
  89. package/dist/provenance/index.d.ts +26 -0
  90. package/dist/provenance/index.js +26 -0
  91. package/dist/provenance/local-events.d.ts +1 -0
  92. package/dist/provenance/local-events.js +1 -0
  93. package/dist/provenance/markers.d.ts +21 -0
  94. package/dist/provenance/markers.js +70 -0
  95. package/dist/provenance/multi-frame-anchor-map.d.ts +54 -0
  96. package/dist/provenance/multi-frame-anchor-map.js +109 -0
  97. package/dist/provenance/normalize.d.ts +2 -0
  98. package/dist/provenance/normalize.js +19 -0
  99. package/dist/provenance/overlay-focus-echo.d.ts +17 -0
  100. package/dist/provenance/overlay-focus-echo.js +16 -0
  101. package/dist/provenance/overlay-projection.d.ts +65 -0
  102. package/dist/provenance/overlay-projection.js +201 -0
  103. package/dist/provenance/owner-map.d.ts +55 -0
  104. package/dist/provenance/owner-map.js +239 -0
  105. package/dist/provenance/read-carrier.d.ts +22 -0
  106. package/dist/provenance/read-carrier.js +92 -0
  107. package/dist/provenance/recorder.d.ts +38 -0
  108. package/dist/provenance/recorder.js +82 -0
  109. package/dist/provenance/render-context.d.ts +106 -0
  110. package/dist/provenance/render-context.js +338 -0
  111. package/dist/provenance/reveal-contract.d.ts +44 -0
  112. package/dist/provenance/reveal-contract.js +1 -0
  113. package/dist/provenance/richtext-slice-embed-address.d.ts +20 -0
  114. package/dist/provenance/richtext-slice-embed-address.js +35 -0
  115. package/dist/provenance/runtime-script.d.ts +1 -0
  116. package/dist/provenance/runtime-script.js +2217 -0
  117. package/dist/provenance/sidecar-store.d.ts +63 -0
  118. package/dist/provenance/sidecar-store.js +151 -0
  119. package/dist/provenance/sidecar.d.ts +34 -0
  120. package/dist/provenance/sidecar.js +227 -0
  121. package/dist/provenance/slice-model.d.ts +28 -0
  122. package/dist/provenance/slice-model.js +86 -0
  123. package/dist/provenance/source-identity.d.ts +3 -0
  124. package/dist/provenance/source-identity.js +7 -0
  125. package/dist/provenance/source-map-materializer.d.ts +76 -0
  126. package/dist/provenance/source-map-materializer.js +286 -0
  127. package/dist/provenance/source-registry.d.ts +103 -0
  128. package/dist/provenance/source-registry.js +562 -0
  129. package/dist/provenance/transformed-output-gate.d.ts +161 -0
  130. package/dist/provenance/transformed-output-gate.js +171 -0
  131. package/dist/provenance/types.d.ts +102 -0
  132. package/dist/provenance/types.js +1 -0
  133. package/dist/refresh-policy.d.ts +11 -0
  134. package/dist/refresh-policy.js +14 -0
  135. package/dist/reveal-target.d.ts +8 -0
  136. package/dist/reveal-target.js +47 -0
  137. package/dist/route-paths.d.ts +11 -0
  138. package/dist/route-paths.js +14 -0
  139. package/dist/router-subscriber.d.ts +92 -0
  140. package/dist/router-subscriber.js +44 -0
  141. package/dist/runtime/anchor-types.d.ts +5 -0
  142. package/dist/runtime/anchor-types.js +1 -0
  143. package/dist/runtime/authority-store.d.ts +31 -0
  144. package/dist/runtime/authority-store.js +56 -0
  145. package/dist/runtime/boot.d.ts +52 -0
  146. package/dist/runtime/boot.js +379 -0
  147. package/dist/runtime/content-refresh.d.ts +30 -0
  148. package/dist/runtime/content-refresh.js +68 -0
  149. package/dist/runtime/emit-runtime-error.d.ts +51 -0
  150. package/dist/runtime/emit-runtime-error.js +64 -0
  151. package/dist/runtime/field-proxy.d.ts +112 -0
  152. package/dist/runtime/field-proxy.js +471 -0
  153. package/dist/runtime/form-contract-reporter.d.ts +99 -0
  154. package/dist/runtime/form-contract-reporter.js +391 -0
  155. package/dist/runtime/invalidation-adapter.d.ts +17 -0
  156. package/dist/runtime/invalidation-adapter.js +42 -0
  157. package/dist/runtime/live-query-overlay.d.ts +39 -0
  158. package/dist/runtime/live-query-overlay.js +345 -0
  159. package/dist/runtime/momentary-navigate.d.ts +16 -0
  160. package/dist/runtime/momentary-navigate.js +106 -0
  161. package/dist/runtime/observation-scheduler.d.ts +15 -0
  162. package/dist/runtime/observation-scheduler.js +42 -0
  163. package/dist/runtime/perf-metrics.d.ts +28 -0
  164. package/dist/runtime/perf-metrics.js +90 -0
  165. package/dist/runtime/preview-interaction-policy.d.ts +1 -0
  166. package/dist/runtime/preview-interaction-policy.js +103 -0
  167. package/dist/runtime/preview-overlay-painter.d.ts +27 -0
  168. package/dist/runtime/preview-overlay-painter.js +1732 -0
  169. package/dist/runtime/preview-overlay-sidecar.d.ts +26 -0
  170. package/dist/runtime/preview-overlay-sidecar.js +45 -0
  171. package/dist/runtime/preview-perspective-alignment.d.ts +15 -0
  172. package/dist/runtime/preview-perspective-alignment.js +40 -0
  173. package/dist/runtime/preview-perspective-intent-state.d.ts +23 -0
  174. package/dist/runtime/preview-perspective-intent-state.js +5 -0
  175. package/dist/runtime/preview-transition-state.d.ts +142 -0
  176. package/dist/runtime/preview-transition-state.js +257 -0
  177. package/dist/runtime/render-mode-state.d.ts +51 -0
  178. package/dist/runtime/render-mode-state.js +76 -0
  179. package/dist/runtime/request-id.d.ts +4 -0
  180. package/dist/runtime/request-id.js +8 -0
  181. package/dist/runtime/resolve-schema-field.d.ts +99 -0
  182. package/dist/runtime/resolve-schema-field.js +356 -0
  183. package/dist/runtime/runtime-error-dispatch.d.ts +23 -0
  184. package/dist/runtime/runtime-error-dispatch.js +75 -0
  185. package/dist/runtime/schema-store.d.ts +121 -0
  186. package/dist/runtime/schema-store.js +667 -0
  187. package/dist/runtime/snapshot-assembly.d.ts +104 -0
  188. package/dist/runtime/snapshot-assembly.js +553 -0
  189. package/dist/runtime/snapshot-reporter.d.ts +55 -0
  190. package/dist/runtime/snapshot-reporter.js +375 -0
  191. package/dist/runtime/standalone-overlay-controller.d.ts +33 -0
  192. package/dist/runtime/standalone-overlay-controller.js +281 -0
  193. package/dist/runtime/store.d.ts +307 -0
  194. package/dist/runtime/store.js +1026 -0
  195. package/dist/runtime/windowed-minimap-projection.d.ts +28 -0
  196. package/dist/runtime/windowed-minimap-projection.js +189 -0
  197. package/dist/runtime/workspace-shortcuts.d.ts +30 -0
  198. package/dist/runtime/workspace-shortcuts.js +188 -0
  199. package/dist/sdk-error-copy.d.ts +48 -0
  200. package/dist/sdk-error-copy.js +182 -0
  201. package/dist/select-cursor.d.ts +2 -0
  202. package/dist/select-cursor.js +23 -0
  203. package/dist/server/admission-cookie.d.ts +38 -0
  204. package/dist/server/admission-cookie.js +122 -0
  205. package/dist/server/control-client.d.ts +29 -0
  206. package/dist/server/control-client.js +22 -0
  207. package/dist/server/control-errors.d.ts +22 -0
  208. package/dist/server/control-errors.js +182 -0
  209. package/dist/server/draft-route.d.ts +12 -0
  210. package/dist/server/draft-route.js +45 -0
  211. package/dist/server/extract-token.d.ts +30 -0
  212. package/dist/server/extract-token.js +98 -0
  213. package/dist/server/grant.d.ts +128 -0
  214. package/dist/server/grant.js +395 -0
  215. package/dist/server/ordinary-read-provenance.d.ts +11 -0
  216. package/dist/server/ordinary-read-provenance.js +124 -0
  217. package/dist/server/preview-read-metadata.d.ts +10 -0
  218. package/dist/server/preview-read-metadata.js +27 -0
  219. package/dist/server/provenance-frame-builder.d.ts +53 -0
  220. package/dist/server/provenance-frame-builder.js +102 -0
  221. package/dist/server/provenance-runtime.d.ts +70 -0
  222. package/dist/server/provenance-runtime.js +46 -0
  223. package/dist/server/read-context-command.d.ts +11 -0
  224. package/dist/server/read-context-command.js +53 -0
  225. package/dist/server/read-identity.d.ts +3 -0
  226. package/dist/server/read-identity.js +63 -0
  227. package/dist/server/read-lifecycle.d.ts +17 -0
  228. package/dist/server/read-lifecycle.js +52 -0
  229. package/dist/server/render-frame-provenance.d.ts +133 -0
  230. package/dist/server/render-frame-provenance.js +676 -0
  231. package/dist/server/render-id.d.ts +15 -0
  232. package/dist/server/render-id.js +22 -0
  233. package/dist/server/route-document-provenance.d.ts +77 -0
  234. package/dist/server/route-document-provenance.js +1327 -0
  235. package/dist/server/route-session-cookie.d.ts +33 -0
  236. package/dist/server/route-session-cookie.js +54 -0
  237. package/dist/session-projections.d.ts +33 -0
  238. package/dist/session-projections.js +43 -0
  239. package/dist/session-subscriber.d.ts +124 -0
  240. package/dist/session-subscriber.js +79 -0
  241. package/dist/sha256-fallback.d.ts +30 -0
  242. package/dist/sha256-fallback.js +126 -0
  243. package/dist/types.d.ts +446 -0
  244. package/dist/types.js +85 -0
  245. package/dist/validators.d.ts +61 -0
  246. package/dist/validators.js +110 -0
  247. package/package.json +210 -3
@@ -0,0 +1,84 @@
1
+ import { PREVIEW_SESSION_QUERY_PARAM, readPreviewSessionIdFromSearch, hasPreviewSessionParam } from './session-query.js';
2
+ export { PREVIEW_SESSION_QUERY_PARAM, readPreviewSessionIdFromSearch, hasPreviewSessionParam } from './session-query.js';
3
+ export const PREVIEW_VARIANT_QUERY_PARAM = '_ss_variant';
4
+ export const PREVIEW_RELEASE_QUERY_PARAM = '_ss_release';
5
+ export function isPreviewIdentity(value) {
6
+ if (value === null || typeof value !== 'object')
7
+ return false;
8
+ const identity = value;
9
+ return (typeof identity.sessionId === 'string' &&
10
+ identity.sessionId.length > 0 &&
11
+ typeof identity.projectId === 'string' &&
12
+ identity.projectId.length > 0 &&
13
+ (identity.parentOrigin === null ||
14
+ typeof identity.parentOrigin === 'string') &&
15
+ (identity.source === 'broadcast' ||
16
+ identity.source === 'request' ||
17
+ identity.source === 'storage' ||
18
+ identity.source === 'url') &&
19
+ typeof identity.epoch === 'number' &&
20
+ Number.isFinite(identity.epoch) &&
21
+ typeof identity.resolvedAt === 'number' &&
22
+ Number.isFinite(identity.resolvedAt));
23
+ }
24
+ export function readPreviewIdentitySessionId(identity) {
25
+ return isPreviewIdentity(identity) ? identity.sessionId : null;
26
+ }
27
+ export function readPreviewIdentity(source) {
28
+ if (source === null || source === undefined)
29
+ return null;
30
+ if (typeof source.getIdentity !== 'function')
31
+ return null;
32
+ try {
33
+ const identity = source.getIdentity();
34
+ return isPreviewIdentity(identity) ? identity : null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ export function readPreviewIdentitySessionIdFromSource(source) {
41
+ return readPreviewIdentitySessionId(readPreviewIdentity(source));
42
+ }
43
+ export function readPreviewVariantIdFromSearch(search, paramName = PREVIEW_VARIANT_QUERY_PARAM) {
44
+ try {
45
+ const variantId = new URLSearchParams(search).get(paramName);
46
+ return normalizePreviewVariantId(variantId);
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ export function normalizePreviewVariantId(value) {
53
+ if (typeof value !== 'string')
54
+ return null;
55
+ const trimmed = value.trim();
56
+ return trimmed.length > 0 ? trimmed : null;
57
+ }
58
+ export function readPreviewReleaseIdFromSearch(search, paramName = PREVIEW_RELEASE_QUERY_PARAM) {
59
+ try {
60
+ const releaseId = new URLSearchParams(search).get(paramName);
61
+ return normalizePreviewReleaseId(releaseId);
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ export function normalizePreviewReleaseId(value) {
68
+ if (typeof value !== 'string')
69
+ return null;
70
+ const trimmed = value.trim();
71
+ return trimmed.length > 0 ? trimmed : null;
72
+ }
73
+ export function detectPreviewSessionFromWindow(win = typeof window === 'undefined' ? null : window, paramName = PREVIEW_SESSION_QUERY_PARAM) {
74
+ if (win === null || win === undefined) {
75
+ return { active: false, sessionId: null, variantId: null, releaseId: null };
76
+ }
77
+ const search = typeof win.location?.search === 'string' ? win.location.search : '';
78
+ return {
79
+ active: hasPreviewSessionParam(search, paramName),
80
+ sessionId: readPreviewSessionIdFromSearch(search, paramName),
81
+ variantId: readPreviewVariantIdFromSearch(search),
82
+ releaseId: readPreviewReleaseIdFromSearch(search),
83
+ };
84
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The content perspective selected by one preview view.
3
+ *
4
+ * Authorization deliberately does not live here: a durable preview
5
+ * capability answers "may this browser read preview content?", while this
6
+ * value answers "which preview content should this tab render?".
7
+ */
8
+ export type PreviewScope = {
9
+ kind: 'living';
10
+ } | {
11
+ kind: 'published';
12
+ } | {
13
+ kind: 'variant';
14
+ variantId: string;
15
+ } | {
16
+ kind: 'release';
17
+ releaseId: string;
18
+ };
19
+ export declare class PreviewScopeConflictError extends Error {
20
+ constructor();
21
+ }
22
+ export declare function createPreviewScope(input: {
23
+ variantId?: string | null;
24
+ releaseId?: string | null;
25
+ }): PreviewScope;
26
+ export declare function readPreviewScopeFromSearch(search: string): PreviewScope;
27
+ export declare function writePreviewScopeToSearchParams(params: URLSearchParams, scope: PreviewScope): void;
28
+ export declare function previewScopeKey(scope: PreviewScope): string;
@@ -0,0 +1,47 @@
1
+ import { PREVIEW_RELEASE_QUERY_PARAM, PREVIEW_VARIANT_QUERY_PARAM, normalizePreviewReleaseId, normalizePreviewVariantId, } from './preview-identity.js';
2
+ export class PreviewScopeConflictError extends Error {
3
+ constructor() {
4
+ super('A preview view cannot select a variant and a release together.');
5
+ this.name = 'PreviewScopeConflictError';
6
+ }
7
+ }
8
+ export function createPreviewScope(input) {
9
+ const variantId = normalizePreviewVariantId(input.variantId);
10
+ const releaseId = normalizePreviewReleaseId(input.releaseId);
11
+ if (variantId && releaseId)
12
+ throw new PreviewScopeConflictError();
13
+ if (releaseId)
14
+ return { kind: 'release', releaseId };
15
+ if (variantId)
16
+ return { kind: 'variant', variantId };
17
+ return { kind: 'living' };
18
+ }
19
+ export function readPreviewScopeFromSearch(search) {
20
+ const params = new URLSearchParams(search);
21
+ return createPreviewScope({
22
+ variantId: params.get(PREVIEW_VARIANT_QUERY_PARAM),
23
+ releaseId: params.get(PREVIEW_RELEASE_QUERY_PARAM),
24
+ });
25
+ }
26
+ export function writePreviewScopeToSearchParams(params, scope) {
27
+ params.delete(PREVIEW_VARIANT_QUERY_PARAM);
28
+ params.delete(PREVIEW_RELEASE_QUERY_PARAM);
29
+ if (scope.kind === 'variant') {
30
+ params.set(PREVIEW_VARIANT_QUERY_PARAM, scope.variantId);
31
+ }
32
+ else if (scope.kind === 'release') {
33
+ params.set(PREVIEW_RELEASE_QUERY_PARAM, scope.releaseId);
34
+ }
35
+ }
36
+ export function previewScopeKey(scope) {
37
+ switch (scope.kind) {
38
+ case 'published':
39
+ return 'published';
40
+ case 'living':
41
+ return 'living';
42
+ case 'variant':
43
+ return `variant:${scope.variantId}`;
44
+ case 'release':
45
+ return `release:${scope.releaseId}`;
46
+ }
47
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * ADR-116 D3 + D5 + D10 — preview identity resolver.
3
+ *
4
+ * Orchestrates the carrier chain (broadcast > storage > URL), the
5
+ * state machine (`identity-state-machine.ts`), and the side effects
6
+ * (storage writes on broadcast confirmation, postMessage listener
7
+ * attach/detach, carrier-miss timeout, recovery backoff).
8
+ *
9
+ * The resolver itself is framework-agnostic : it knows nothing about
10
+ * React, Next, Convex. The runtime store (PR4) is the framework-aware
11
+ * consumer that ties the resolver to the PreviewGrant + Convex client
12
+ * lifecycle.
13
+ *
14
+ * D5 invariants (codex R6) :
15
+ * - `init()` is async + retriable, returns Promise<void>, never
16
+ * rejects ; failure modes surface via `state === 'error'`.
17
+ * - Compare-and-set on state transitions ; concurrent `start()` calls
18
+ * resolve to "first wins, subsequent observe new state and bail".
19
+ * - HMR-durability via the runtime cell (PR4 wires globalThis ; the
20
+ * resolver itself is a single per-iframe instance owned by store.ts).
21
+ */
22
+ import type { Carrier, IdentityListener, IdentitySnapshot, PreviewIdentity, ProvisionalIdentity } from './identity-types.js';
23
+ import { STORAGE_KEY } from './carriers/storage.js';
24
+ /** Default carrier-miss timeout (D5). 3 seconds matches the ADR. */
25
+ export declare const DEFAULT_CARRIER_TIMEOUT_MS = 3000;
26
+ /** Recovery backoff (ms) — exponential, capped. D4. */
27
+ export declare const RECOVERY_BACKOFF_MS: readonly [200, 800, 2000];
28
+ export interface CreateResolverOpts {
29
+ /** Authoritative projectId from the `<Storystick projectId>` prop. */
30
+ expectedProjectId: string;
31
+ /** Strict-precedence carriers. The broadcast carrier is reactive
32
+ * (postMessage listener) and not part of the read-pass list. */
33
+ carriers: {
34
+ storage?: Carrier;
35
+ url?: Carrier;
36
+ };
37
+ /**
38
+ * Carrier-miss timeout (D5). When all carriers miss AND no broadcast
39
+ * arrives within this window, the resolver transitions to `error`.
40
+ * Test harnesses inject a small value (10ms) for deterministic
41
+ * timeout tests.
42
+ */
43
+ carrierTimeoutMs?: number;
44
+ /** Wall clock — injectable. */
45
+ now?: () => number;
46
+ /**
47
+ * Optional override for the `window` that receives broadcasts.
48
+ * Tests pass a jsdom stub. Production uses the global `window`.
49
+ */
50
+ win?: Window;
51
+ /**
52
+ * Optional override for `window.parent` that the resolver targets
53
+ * with session-request envelopes. Tests pass a stub.
54
+ */
55
+ parent?: Window;
56
+ /**
57
+ * Storage writer — fires on every confirmed broadcast (D7 A3 write
58
+ * timing). Defaults to the sessionStorage writer in `carriers/storage`.
59
+ * Test harnesses override with an in-memory spy.
60
+ */
61
+ storageWriter?: (identity: PreviewIdentity) => void;
62
+ /**
63
+ * Storage clearer — fires on identity invalidation (e.g. recovery
64
+ * exhausted, project mismatch). The default clears embedded storage and
65
+ * retains a standalone public hint for retry/exit, never an authority.
66
+ */
67
+ storageClearer?: () => void;
68
+ }
69
+ export interface IdentityResolver {
70
+ /**
71
+ * Begin carrier resolution. Idempotent — concurrent calls collapse
72
+ * to the in-flight promise. Returns `Promise<void>` that resolves
73
+ * once the state machine leaves `resolving-identity` (transition
74
+ * to `handshaking` or `error`). NEVER rejects ; failure surfaces
75
+ * via `state === 'error'`.
76
+ */
77
+ start(): Promise<void>;
78
+ /** Tear down listeners + clear pending timeouts. */
79
+ stop(): void;
80
+ /** Test-only reset to initial snapshot ; clears listeners. */
81
+ reset(): void;
82
+ getSnapshot(): IdentitySnapshot;
83
+ subscribe(listener: IdentityListener): () => void;
84
+ /**
85
+ * External notifications. The runtime store projects PreviewGrant and
86
+ * subscription readiness through the legacy identity state machine.
87
+ */
88
+ notifyAuthSucceeded(): void;
89
+ notifyAuthFailed(err: {
90
+ code: string;
91
+ message: string;
92
+ }): void;
93
+ /**
94
+ * Mark the channel as disconnected. The resolver attempts up to
95
+ * `MAX_RECOVERY_RETRIES` re-handshakes before transitioning to
96
+ * `error`.
97
+ */
98
+ notifyDisconnect(err?: {
99
+ code: string;
100
+ message: string;
101
+ }): void;
102
+ /**
103
+ * Inject a broadcast envelope (for test harnesses + direct unit
104
+ * tests). The resolver's attached `message` listener invokes this
105
+ * automatically on legitimate envelopes.
106
+ */
107
+ ingestBroadcast(identity: ProvisionalIdentity, eventOrigin: string): void;
108
+ }
109
+ export declare function createIdentityResolver(opts: CreateResolverOpts): IdentityResolver;
110
+ export { STORAGE_KEY };
@@ -0,0 +1,334 @@
1
+ /**
2
+ * ADR-116 D3 + D5 + D10 — preview identity resolver.
3
+ *
4
+ * Orchestrates the carrier chain (broadcast > storage > URL), the
5
+ * state machine (`identity-state-machine.ts`), and the side effects
6
+ * (storage writes on broadcast confirmation, postMessage listener
7
+ * attach/detach, carrier-miss timeout, recovery backoff).
8
+ *
9
+ * The resolver itself is framework-agnostic : it knows nothing about
10
+ * React, Next, Convex. The runtime store (PR4) is the framework-aware
11
+ * consumer that ties the resolver to the PreviewGrant + Convex client
12
+ * lifecycle.
13
+ *
14
+ * D5 invariants (codex R6) :
15
+ * - `init()` is async + retriable, returns Promise<void>, never
16
+ * rejects ; failure modes surface via `state === 'error'`.
17
+ * - Compare-and-set on state transitions ; concurrent `start()` calls
18
+ * resolve to "first wins, subsequent observe new state and bail".
19
+ * - HMR-durability via the runtime cell (PR4 wires globalThis ; the
20
+ * resolver itself is a single per-iframe instance owned by store.ts).
21
+ */
22
+ import { INITIAL_SNAPSHOT, MAX_RECOVERY_RETRIES, transition, } from './identity-state-machine.js';
23
+ import { attachBroadcastListener, postSessionRequest } from './carriers/postmessage.js';
24
+ import { STORAGE_KEY, clearStoredAfterAuthFailure, writeStored, } from './carriers/storage.js';
25
+ /** Default carrier-miss timeout (D5). 3 seconds matches the ADR. */
26
+ export const DEFAULT_CARRIER_TIMEOUT_MS = 3_000;
27
+ /** Recovery backoff (ms) — exponential, capped. D4. */
28
+ export const RECOVERY_BACKOFF_MS = [200, 800, 2_000];
29
+ export function createIdentityResolver(opts) {
30
+ const expectedProjectId = opts.expectedProjectId;
31
+ if (typeof expectedProjectId !== 'string' || expectedProjectId.length === 0) {
32
+ throw new Error('createIdentityResolver: expectedProjectId is required');
33
+ }
34
+ const now = opts.now ?? Date.now;
35
+ const carrierTimeoutMs = opts.carrierTimeoutMs ?? DEFAULT_CARRIER_TIMEOUT_MS;
36
+ const storageWriter = opts.storageWriter ?? ((id) => void writeStored(id, now));
37
+ const storageClearer = opts.storageClearer ?? clearStoredAfterAuthFailure;
38
+ // Resolver-local state cell. PR4's store.ts persists this on
39
+ // `globalThis` to survive HMR. For PR1 the resolver is a fresh per-
40
+ // creation instance ; tests that span HMR cycles construct a new
41
+ // resolver explicitly.
42
+ let snap = { ...INITIAL_SNAPSHOT };
43
+ let inFlightStartPromise = null;
44
+ let carrierTimeoutTimer = null;
45
+ let detachBroadcast = null;
46
+ let recoveryTimer = null;
47
+ // Locked parent origin after the first confirmed broadcast (D2).
48
+ let lockedParentOrigin = null;
49
+ // Listeners — Set so subscribe/unsubscribe is O(1).
50
+ const listeners = new Set();
51
+ function notify() {
52
+ const current = snap;
53
+ for (const listener of listeners) {
54
+ try {
55
+ listener(current);
56
+ }
57
+ catch {
58
+ // Defensive — a listener exception must not break the fan-out.
59
+ }
60
+ }
61
+ }
62
+ function dispatch(event) {
63
+ const prev = snap;
64
+ const next = transition(prev, event);
65
+ if (next === prev)
66
+ return;
67
+ snap = next;
68
+ notify();
69
+ }
70
+ function emitSessionRequest(reason) {
71
+ postSessionRequest({
72
+ projectId: expectedProjectId,
73
+ reason,
74
+ parent: opts.parent,
75
+ now,
76
+ });
77
+ }
78
+ function attachBroadcast() {
79
+ if (detachBroadcast !== null)
80
+ return;
81
+ detachBroadcast = attachBroadcastListener({
82
+ expectedProjectId,
83
+ win: opts.win,
84
+ onBroadcast: (identity, eventOrigin) => {
85
+ // Origin-lock — refuse subsequent broadcasts from any other
86
+ // origin (D2). The first confirmed broadcast wins.
87
+ if (lockedParentOrigin === null) {
88
+ lockedParentOrigin = eventOrigin;
89
+ }
90
+ else if (lockedParentOrigin !== eventOrigin) {
91
+ return;
92
+ }
93
+ ingestBroadcastInternal(identity);
94
+ },
95
+ });
96
+ }
97
+ function ingestBroadcastInternal(identity) {
98
+ // D10 reject path — projectId mismatch when an identity is already
99
+ // established. Bail BEFORE dispatching so the state machine never
100
+ // sees the rejected event AND the storage writer never fires.
101
+ if (snap.identity !== null &&
102
+ snap.identity.projectId !== identity.projectId) {
103
+ return;
104
+ }
105
+ // Dispatch and observe whether the state machine accepted the
106
+ // broadcast. Acceptance is detected by comparing post-dispatch
107
+ // identity tuple to the incoming one.
108
+ dispatch({ type: 'broadcast-received', identity, now: now() });
109
+ const after = snap;
110
+ const accepted = after.identity !== null &&
111
+ after.identity.sessionId === identity.sessionId &&
112
+ after.identity.projectId === identity.projectId;
113
+ if (accepted) {
114
+ // D7 A3 write timing — fires on every confirmed broadcast
115
+ // (Confirm / Promote / Replace AND idempotent no-ops so the TTL
116
+ // refreshes). Hardened mode storage exceptions are silent.
117
+ try {
118
+ storageWriter(after.identity);
119
+ }
120
+ catch {
121
+ /* hardened mode — silent */
122
+ }
123
+ }
124
+ // First broadcast = clear the carrier-miss timeout.
125
+ if (after.state !== 'resolving-identity' &&
126
+ carrierTimeoutTimer !== null) {
127
+ clearTimeout(carrierTimeoutTimer);
128
+ carrierTimeoutTimer = null;
129
+ }
130
+ }
131
+ function readCarrierChain() {
132
+ const ctx = { expectedProjectId, now };
133
+ // Storage first (tier-2), URL last (tier-3 bootstrap). Live
134
+ // broadcast (tier-1) is reactive ; it does not appear in this
135
+ // chain.
136
+ for (const carrierKey of ['storage', 'url']) {
137
+ const carrier = opts.carriers[carrierKey];
138
+ if (!carrier)
139
+ continue;
140
+ const result = carrier.read(ctx);
141
+ if (result.kind === 'hit') {
142
+ return { source: carrier.name, identity: result.identity };
143
+ }
144
+ // miss / error → continue to next carrier.
145
+ }
146
+ return null;
147
+ }
148
+ function start() {
149
+ // Compare-and-set : only the first caller transitions idle →
150
+ // resolving-identity. Concurrent callers (StrictMode double-mount,
151
+ // HMR re-mount) observe the in-flight promise and await it.
152
+ if (snap.state !== 'idle' && snap.state !== 'disconnected') {
153
+ return inFlightStartPromise ?? Promise.resolve();
154
+ }
155
+ // CRITICAL — set inFlightStartPromise BEFORE any notify() (codex
156
+ // R6 #2). A synchronous reentrancy via the listener fan-out must
157
+ // see the slot populated, not null.
158
+ const flight = new Promise((resolve) => {
159
+ const unsubscribe = subscribe((current) => {
160
+ if (current.state === 'handshaking' ||
161
+ current.state === 'authenticated' ||
162
+ current.state === 'error') {
163
+ unsubscribe();
164
+ inFlightStartPromise = null;
165
+ resolve();
166
+ }
167
+ });
168
+ });
169
+ inFlightStartPromise = flight;
170
+ attachBroadcast();
171
+ dispatch({ type: 'start' });
172
+ // Carrier read-pass — synchronous.
173
+ const carrierHit = readCarrierChain();
174
+ if (carrierHit !== null) {
175
+ dispatch({
176
+ type: 'carrier-hit',
177
+ source: carrierHit.source,
178
+ identity: carrierHit.identity,
179
+ now: now(),
180
+ });
181
+ // ADR-131 C5 — URL / storage carriers do NOT carry parentOrigin
182
+ // (only the broadcast carrier does — `postmessage.ts` line 115).
183
+ // Without this emission the iframe's `identity.parentOrigin`
184
+ // stays `null` forever on the fast carrier-hit path, which
185
+ // breaks downstream preview message validators
186
+ // (validators reject unconditionally when
187
+ // parentOrigin === null). Emit a `session-request` so Studio
188
+ // sends back a broadcast that promotes identity with the locked
189
+ // parentOrigin (`identity-state-machine.ts:246` promotion
190
+ // branch). The Studio side already handles unsolicited drains
191
+ // via the queue+drain pattern in `use-preview-auth-bridge.ts` —
192
+ // the broadcast lands ~O(1ms) later, well before the first user
193
+ // edit triggers a build envelope.
194
+ emitSessionRequest('boot');
195
+ }
196
+ else {
197
+ // No carrier hit — arm the timeout AND post the session-request.
198
+ // Broadcast may still arrive within the window and resolve the
199
+ // identity ; if not, the timeout fires `error`.
200
+ emitSessionRequest('boot');
201
+ carrierTimeoutTimer = setTimeout(() => {
202
+ carrierTimeoutTimer = null;
203
+ dispatch({ type: 'carrier-timeout' });
204
+ }, carrierTimeoutMs);
205
+ }
206
+ return flight;
207
+ }
208
+ function stop() {
209
+ if (carrierTimeoutTimer !== null) {
210
+ clearTimeout(carrierTimeoutTimer);
211
+ carrierTimeoutTimer = null;
212
+ }
213
+ if (recoveryTimer !== null) {
214
+ clearTimeout(recoveryTimer);
215
+ recoveryTimer = null;
216
+ }
217
+ if (detachBroadcast !== null) {
218
+ detachBroadcast();
219
+ detachBroadcast = null;
220
+ }
221
+ inFlightStartPromise = null;
222
+ }
223
+ function reset() {
224
+ stop();
225
+ snap = { ...INITIAL_SNAPSHOT };
226
+ listeners.clear();
227
+ lockedParentOrigin = null;
228
+ }
229
+ function subscribe(listener) {
230
+ listeners.add(listener);
231
+ return () => {
232
+ listeners.delete(listener);
233
+ };
234
+ }
235
+ function notifyAuthSucceeded() {
236
+ dispatch({ type: 'auth-succeeded' });
237
+ }
238
+ function notifyAuthFailed(err) {
239
+ dispatch({ type: 'auth-failed', code: err.code, message: err.message });
240
+ if (snap.state === 'error') {
241
+ // Embedded failures invalidate the carrier. Autonomous views retain
242
+ // their public hint so a second reload can still offer Exit Preview.
243
+ try {
244
+ storageClearer();
245
+ }
246
+ catch {
247
+ /* hardened mode — silent */
248
+ }
249
+ }
250
+ }
251
+ function scheduleRecovery() {
252
+ if (recoveryTimer !== null)
253
+ return;
254
+ if (snap.retryCount >= MAX_RECOVERY_RETRIES) {
255
+ dispatch({
256
+ type: 'recovery-exhausted',
257
+ code: 'identity_recovery_exhausted',
258
+ message: 'Preview connection lost and recovery attempts exhausted. Reopen the document in Storystick Studio to retry.',
259
+ });
260
+ try {
261
+ storageClearer();
262
+ }
263
+ catch {
264
+ /* hardened mode — silent */
265
+ }
266
+ return;
267
+ }
268
+ // retryCount on the snapshot is incremented BY recovery-begin so the
269
+ // index for the backoff array is the current retryCount (0 → 200ms,
270
+ // 1 → 800ms, 2 → 2000ms).
271
+ const delay = RECOVERY_BACKOFF_MS[snap.retryCount] ??
272
+ RECOVERY_BACKOFF_MS[RECOVERY_BACKOFF_MS.length - 1];
273
+ recoveryTimer = setTimeout(() => {
274
+ recoveryTimer = null;
275
+ // Bail if state moved (e.g. broadcast already lifted us out of
276
+ // disconnected mid-backoff).
277
+ if (snap.state !== 'disconnected')
278
+ return;
279
+ dispatch({ type: 'recovery-begin' });
280
+ // After recovery-begin we are back in resolving-identity ; emit a
281
+ // recovery session-request and arm the carrier-miss timeout. We do
282
+ // NOT re-read storage/URL : on recovery, the identity should come
283
+ // from a fresh broadcast, not a stale tier-2 entry.
284
+ emitSessionRequest('recovery');
285
+ if (carrierTimeoutTimer !== null)
286
+ clearTimeout(carrierTimeoutTimer);
287
+ carrierTimeoutTimer = setTimeout(() => {
288
+ carrierTimeoutTimer = null;
289
+ dispatch({ type: 'carrier-timeout' });
290
+ // If carrier-timeout fired during recovery, we're now in error
291
+ // — clear storage to avoid auto-restoring on the next mount.
292
+ if (snap.state === 'error') {
293
+ try {
294
+ storageClearer();
295
+ }
296
+ catch {
297
+ /* hardened mode */
298
+ }
299
+ }
300
+ }, carrierTimeoutMs);
301
+ }, delay);
302
+ }
303
+ function notifyDisconnect(err) {
304
+ dispatch({ type: 'disconnect', code: err?.code, message: err?.message });
305
+ if (snap.state === 'disconnected') {
306
+ scheduleRecovery();
307
+ }
308
+ }
309
+ function ingestBroadcast(identity, eventOrigin) {
310
+ if (lockedParentOrigin === null) {
311
+ lockedParentOrigin = eventOrigin;
312
+ }
313
+ else if (lockedParentOrigin !== eventOrigin) {
314
+ return;
315
+ }
316
+ ingestBroadcastInternal(identity);
317
+ }
318
+ function getSnapshot() {
319
+ return snap;
320
+ }
321
+ return {
322
+ start,
323
+ stop,
324
+ reset,
325
+ getSnapshot,
326
+ subscribe,
327
+ notifyAuthSucceeded,
328
+ notifyAuthFailed,
329
+ notifyDisconnect,
330
+ ingestBroadcast,
331
+ };
332
+ }
333
+ // Re-export STORAGE_KEY for the namespace-pinning test.
334
+ export { STORAGE_KEY };
@@ -0,0 +1,4 @@
1
+ /** Public URL hint parsing only; session admission remains server-owned. */
2
+ export declare const PREVIEW_SESSION_QUERY_PARAM = "_ss_session";
3
+ export declare function readPreviewSessionIdFromSearch(search: string, paramName?: string): string | null;
4
+ export declare function hasPreviewSessionParam(search: string, paramName?: string): boolean;
@@ -0,0 +1,19 @@
1
+ /** Public URL hint parsing only; session admission remains server-owned. */
2
+ export const PREVIEW_SESSION_QUERY_PARAM = '_ss_session';
3
+ export function readPreviewSessionIdFromSearch(search, paramName = PREVIEW_SESSION_QUERY_PARAM) {
4
+ try {
5
+ const sessionId = new URLSearchParams(search).get(paramName);
6
+ return sessionId !== null && sessionId.length > 0 ? sessionId : null;
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
12
+ export function hasPreviewSessionParam(search, paramName = PREVIEW_SESSION_QUERY_PARAM) {
13
+ try {
14
+ return new URLSearchParams(search).has(paramName);
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }