@storystick/client 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.
Files changed (75) hide show
  1. package/LICENSE +112 -0
  2. package/PUBLIC-API.md +22 -0
  3. package/README.md +180 -2
  4. package/dist/.source-hash +1 -0
  5. package/dist/_fetchers.d.ts +51 -0
  6. package/dist/_fetchers.js +144 -0
  7. package/dist/_stateMachine.d.ts +102 -0
  8. package/dist/_stateMachine.js +168 -0
  9. package/dist/_wireAuth.d.ts +48 -0
  10. package/dist/_wireAuth.js +52 -0
  11. package/dist/adapter.d.ts +14 -0
  12. package/dist/adapter.js +10 -0
  13. package/dist/content-values.d.ts +11 -0
  14. package/dist/content-values.js +401 -0
  15. package/dist/errors.d.ts +65 -0
  16. package/dist/errors.js +45 -0
  17. package/dist/hydrate-slice.d.ts +2 -0
  18. package/dist/hydrate-slice.js +1 -0
  19. package/dist/index.d.ts +23 -0
  20. package/dist/index.js +9 -0
  21. package/dist/infra-resolver.d.ts +94 -0
  22. package/dist/infra-resolver.js +130 -0
  23. package/dist/internal.d.ts +33 -0
  24. package/dist/internal.js +20 -0
  25. package/dist/locale-routing.d.ts +5 -0
  26. package/dist/locale-routing.js +33 -0
  27. package/dist/normalize-pathname.d.ts +79 -0
  28. package/dist/normalize-pathname.js +103 -0
  29. package/dist/preview-frame-dependencies.d.ts +39 -0
  30. package/dist/preview-frame-dependencies.js +84 -0
  31. package/dist/preview-query-receipt.d.ts +3 -0
  32. package/dist/preview-query-receipt.js +16 -0
  33. package/dist/preview-read-lifecycle.d.ts +10 -0
  34. package/dist/preview-read-lifecycle.js +40 -0
  35. package/dist/preview-read-observation.d.ts +18 -0
  36. package/dist/preview-read-observation.js +81 -0
  37. package/dist/preview-scope.d.ts +3 -0
  38. package/dist/preview-scope.js +22 -0
  39. package/dist/preview-target.d.ts +73 -0
  40. package/dist/preview-target.js +86 -0
  41. package/dist/project-locales.d.ts +4 -0
  42. package/dist/project-locales.js +12 -0
  43. package/dist/public-delivery.d.ts +36 -0
  44. package/dist/public-delivery.js +55 -0
  45. package/dist/published-routes.d.ts +3 -0
  46. package/dist/published-routes.js +100 -0
  47. package/dist/query-builder.d.ts +225 -0
  48. package/dist/query-builder.js +294 -0
  49. package/dist/query-client.d.ts +54 -0
  50. package/dist/query-client.js +306 -0
  51. package/dist/query-compiler.d.ts +5 -0
  52. package/dist/query-compiler.js +12 -0
  53. package/dist/query-schema.d.ts +32 -0
  54. package/dist/query-schema.js +1 -0
  55. package/dist/redirect-search.d.ts +4 -0
  56. package/dist/redirect-search.js +14 -0
  57. package/dist/reference-resolver.d.ts +37 -0
  58. package/dist/reference-resolver.js +126 -0
  59. package/dist/resolve-route.d.ts +27 -0
  60. package/dist/resolve-route.js +145 -0
  61. package/dist/resolve.d.ts +43 -0
  62. package/dist/resolve.js +160 -0
  63. package/dist/schemas.d.ts +71 -0
  64. package/dist/schemas.js +75 -0
  65. package/dist/seo.d.ts +52 -0
  66. package/dist/seo.js +93 -0
  67. package/dist/signed-webhook.d.ts +13 -0
  68. package/dist/signed-webhook.js +54 -0
  69. package/dist/transport.d.ts +29 -0
  70. package/dist/transport.js +584 -0
  71. package/dist/typed-route.d.ts +22 -0
  72. package/dist/typed-route.js +1 -0
  73. package/dist/types.d.ts +147 -0
  74. package/dist/types.js +1 -0
  75. package/package.json +70 -3
@@ -0,0 +1,54 @@
1
+ /** Shared HTTP boundary for framework delivery adapters. No cache or queue policy. */
2
+ export async function signWebhookBody(body, secret) {
3
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
4
+ const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body));
5
+ return `sha256=${Array.from(new Uint8Array(signature), byte => byte.toString(16).padStart(2, '0')).join('')}`;
6
+ }
7
+ export async function readSignedWebhook(request, options) {
8
+ const limit = 1024 * 1024;
9
+ const failure = (status, error) => ({ ok: false, status, error });
10
+ if (Number(request.headers.get('content-length')) > limit)
11
+ return failure(413, 'payload_too_large');
12
+ const signature = request.headers.get(options.signatureHeader ?? 'x-storystick-signature')?.trim().replace(/^sha256=/, '');
13
+ if (!signature || !/^[a-f0-9]{64}$/i.test(signature))
14
+ return failure(401, 'invalid_signature');
15
+ const chunks = [];
16
+ let size = 0;
17
+ const reader = request.body?.getReader();
18
+ try {
19
+ if (reader)
20
+ while (true) {
21
+ const { done, value } = await reader.read();
22
+ if (done)
23
+ break;
24
+ size += value.byteLength;
25
+ if (size > limit) {
26
+ await reader.cancel();
27
+ return failure(413, 'payload_too_large');
28
+ }
29
+ chunks.push(value);
30
+ }
31
+ }
32
+ catch {
33
+ return failure(400, 'invalid_body');
34
+ }
35
+ finally {
36
+ reader?.releaseLock();
37
+ }
38
+ const bytes = new Uint8Array(size);
39
+ let offset = 0;
40
+ for (const chunk of chunks) {
41
+ bytes.set(chunk, offset);
42
+ offset += chunk.length;
43
+ }
44
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(options.secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
45
+ const candidate = Uint8Array.from(signature.match(/../g), byte => parseInt(byte, 16));
46
+ if (!await crypto.subtle.verify('HMAC', key, candidate, bytes))
47
+ return failure(401, 'invalid_signature');
48
+ try {
49
+ return { ok: true, payload: JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) };
50
+ }
51
+ catch {
52
+ return failure(400, 'invalid_json');
53
+ }
54
+ }
@@ -0,0 +1,29 @@
1
+ import type { PublicTransport, TransportResponse } from './types.js';
2
+ export interface HttpTransportOptions {
3
+ /** Project short ID expected by this client; this never selects a server tenant. */
4
+ projectId: string;
5
+ /** Explicit CDN origin for public Query V2 only. */
6
+ deliveryEndpoint?: string;
7
+ /** Resolved Storystick HTTP gateway URL. */
8
+ endpoint: string;
9
+ /** Caller's long-lived API key (`ss_sk_live_*` or `ss_pk_live_*`). */
10
+ apiKey: string;
11
+ /** Exact customer application build, transmitted with every published read. */
12
+ applicationBuildId?: string;
13
+ /**
14
+ * Optional override of `globalThis.fetch`. Tests pass a stub here
15
+ * to observe wire shape without hitting the network.
16
+ */
17
+ fetchImpl?: typeof fetch;
18
+ }
19
+ /**
20
+ * Build the default Pattern D HTTP transport. Shape matches the
21
+ * `Transport` interface; the configured client wires this up by
22
+ * default and the user can substitute a mock via
23
+ * `createClient({ schema, transport: customTransport })`.
24
+ */
25
+ export declare function createHttpTransportV2(opts: HttpTransportOptions): PublicTransport;
26
+ /** Shared consumption boundary, including injected transports and null results. */
27
+ export declare function assertResponseProject(response: TransportResponse, expected: string): void;
28
+ /** Check nested SDK transport bindings before the inner transport can observe data. */
29
+ export declare function assertProjectMatch(expected: string, actual: string): void;
@@ -0,0 +1,584 @@
1
+ import { isExactPublicQueryResultV2, queryResultHttpStatus, QUERY_HTTP_PROTOCOL_HEADER, QUERY_HTTP_PROTOCOL, QUERY_PROJECT_ID_HEADER } from '@storystick/types/delivery-contract';
2
+ import { isPublicReadIdentity, PUBLIC_DELIVERY_IDENTITY_HEADER } from '@storystick/types/public-delivery';
3
+ /** Shared HTTP reads: direct API keys in production, delegated JWTs in Preview. */
4
+ import { contentShapeCapabilityHeaders } from '@storystick/types';
5
+ import { StorystickError } from './errors.js';
6
+ import { httpFailureRetryable, isTemporaryHttpStatus, parseRetryAfter } from '@storystick/types/http-failure';
7
+ import { hasUsablePreviewToken } from './preview-scope.js';
8
+ import { PROJECT_ID_MISMATCH } from '@storystick/types/errors/codes';
9
+ import { attachPreviewReadObservation } from './preview-read-observation.js';
10
+ import { encodePreviewGatewayContext, PREVIEW_GATEWAY_CONTEXT_HEADER, PREVIEW_QUERY_RECEIPT_HEADER, } from '@storystick/types/preview-query-targeting';
11
+ // ─────────────────────────────────────────────
12
+ // UDF path routing
13
+ // ─────────────────────────────────────────────
14
+ /**
15
+ * Map a Convex-style udf path to a Pattern D HTTP endpoint descriptor.
16
+ * Mirrors the routes Central's `gateway_queries.ts` httpActions register
17
+ * at `/api/q/*` and the consolidated wire layer in
18
+ * `packages/next/src/server.ts` consumed today.
19
+ *
20
+ * `getRouteByPathname` (ADR-094 §1.bis) routes cross-storytype URL lookups
21
+ * to the same family of indexes — one indexed call regardless of how many
22
+ * URL-routable storytypes the project declares (ADR-086 invariant).
23
+ */
24
+ function endpointForUdf(udfPath) {
25
+ switch (udfPath) {
26
+ case 'queries:getRouteByPathname':
27
+ return { method: 'GET', path: '/api/q/getRouteByPathname' };
28
+ case 'queries:listPublishedLocaleRoutes':
29
+ return { method: 'GET', path: '/api/q/listPublishedLocaleRoutes' };
30
+ case 'locales:get':
31
+ return { method: 'GET', path: '/api/q/locales/settings' };
32
+ case 'queries:queryV2':
33
+ return { method: 'POST', path: '/api/q/queryV2' };
34
+ case 'queries:schemas.current':
35
+ // ADR-131 Amendment 002 §3.1 — schema delivery via the existing
36
+ // `/api/q/schemas/current` HTTP action (gateway_queries.ts::schemasCurrent).
37
+ // Consumed by `fetchCurrentSchema` (packages/client/src/schemas.ts).
38
+ return { method: 'GET', path: '/api/q/schemas/current' };
39
+ default:
40
+ throw new StorystickError(`Unknown query path "${udfPath}". Expected one of: ` +
41
+ `queries:queryV2, queries:getRouteByPathname, queries:listPublishedLocaleRoutes, queries:schemas.current, locales:get.`, { code: 'storystick/INVALID_UDF_PATH', category: 'validation' });
42
+ }
43
+ }
44
+ /**
45
+ * Build the default Pattern D HTTP transport. Shape matches the
46
+ * `Transport` interface; the configured client wires this up by
47
+ * default and the user can substitute a mock via
48
+ * `createClient({ schema, transport: customTransport })`.
49
+ */
50
+ export function createHttpTransportV2(opts) {
51
+ if (!opts.projectId?.trim())
52
+ throw new TypeError('storystick/QUERY_INVALID: projectId is required.');
53
+ const { endpoint, apiKey, applicationBuildId, fetchImpl } = opts;
54
+ const fetchFn = fetchImpl ?? globalThis.fetch.bind(globalThis);
55
+ async function request(input) {
56
+ assertProjectMatch(input.expectedProjectId ?? opts.projectId, opts.projectId);
57
+ const { udfPath, args, options, requestContext, signal: signalArg } = input;
58
+ const { method, path } = endpointForUdf(udfPath);
59
+ // Use the shared `hasUsablePreviewToken` predicate so the builder's
60
+ // `assertPreviewIntent` guard and this preview-path switch agree on
61
+ // what counts as "bound": a non-empty string. `previewToken: ''`
62
+ // and runtime `null` previously slipped through `!!previewToken` and
63
+ // produced production reads on a preview-intent client.
64
+ const isPreview = hasUsablePreviewToken(requestContext);
65
+ const previewToken = isPreview ? requestContext.previewToken : undefined;
66
+ const token = isPreview ? previewToken : apiKey;
67
+ // Wire shape build.
68
+ const readEndpoint = !isPreview && udfPath === 'queries:queryV2' && opts.deliveryEndpoint
69
+ ? opts.deliveryEndpoint : endpoint;
70
+ const url = buildUrl({ endpoint: readEndpoint, path, method, args, options, isPreview });
71
+ const body = method === 'POST'
72
+ ? buildPostBody(args, options, isPreview, udfPath !== 'queries:queryV2')
73
+ : undefined;
74
+ const signal = signalArg ?? options?.signal;
75
+ const fetchInit = buildFetchInit({
76
+ method,
77
+ token,
78
+ applicationBuildId,
79
+ isPreview,
80
+ udfPath,
81
+ requestContext,
82
+ options,
83
+ body,
84
+ signal,
85
+ });
86
+ const response = await fetchTransportResponse(fetchFn, url, fetchInit);
87
+ // Only a delegated Preview token can be refreshed. A rejected API key
88
+ // must surface immediately; no mint, fallback or hidden auth retry.
89
+ if (isPreview && await shouldRefreshAndRetry(response)) {
90
+ const refresh = requestContext?.refreshPreviewToken;
91
+ const retryToken = refresh ? await refresh(previewToken) : undefined;
92
+ if (typeof retryToken !== 'string' || retryToken.length === 0) {
93
+ throw new StorystickError('Preview token expired and could not be refreshed. Re-enter Studio preview to obtain a fresh token.', { code: 'storystick/PREVIEW_TOKEN_EXPIRED', category: 'auth', recoverable: true, retryable: false });
94
+ }
95
+ const retryInit = buildFetchInit({
96
+ method,
97
+ token: retryToken,
98
+ isPreview,
99
+ udfPath,
100
+ requestContext,
101
+ options,
102
+ body,
103
+ signal,
104
+ });
105
+ const retry = await fetchTransportResponse(fetchFn, url, retryInit);
106
+ return decode(retry, input, isPreview);
107
+ }
108
+ return decode(response, input, isPreview);
109
+ }
110
+ async function decode(response, input, isPreview) {
111
+ let data;
112
+ if (!response.ok) {
113
+ // Only the closed Query V2 result protocol can survive a non-2xx status.
114
+ // Other errors (including HTML 503s and auth errors) retain their normal path.
115
+ const cardinality = input.args.query?.cardinality;
116
+ if (input.udfPath === 'queries:queryV2' &&
117
+ (response.status === 503 || response.status === 422) &&
118
+ (cardinality === 'one' || cardinality === 'many')) {
119
+ const candidate = await response.clone().json().catch(() => null);
120
+ if (isExactPublicQueryResultV2(candidate, cardinality) &&
121
+ candidate.exactness !== 'exact' && queryResultHttpStatus(candidate) === response.status) {
122
+ data = candidate;
123
+ }
124
+ }
125
+ if (data === undefined)
126
+ throw await buildStorystickError(response);
127
+ }
128
+ else {
129
+ try {
130
+ data = await response.json();
131
+ }
132
+ catch (error) {
133
+ if ((input.signal ?? input.options?.signal)?.aborted)
134
+ throw error;
135
+ const protocol = error instanceof SyntaxError;
136
+ throw new StorystickError(protocol ? 'The Storystick gateway returned invalid JSON.' : 'The Storystick response stream was interrupted.', {
137
+ code: protocol ? 'storystick/QUERY_PROTOCOL' : 'storystick/QUERY_NETWORK',
138
+ category: 'system', retryable: !protocol, status: response.status,
139
+ });
140
+ }
141
+ }
142
+ let publicReadIdentity;
143
+ if (!isPreview && ['queries:queryV2', 'queries:getRouteByPathname', 'queries:listPublishedLocaleRoutes'].includes(input.udfPath) && response.headers.has(PUBLIC_DELIVERY_IDENTITY_HEADER)) {
144
+ let value = null;
145
+ try {
146
+ value = JSON.parse(response.headers.get(PUBLIC_DELIVERY_IDENTITY_HEADER));
147
+ }
148
+ catch { /* Rejected by the identity validator below. */ }
149
+ if (!isPublicReadIdentity(value))
150
+ throw new StorystickError('Invalid public read identity.', { code: 'storystick/QUERY_PROTOCOL', category: 'system', retryable: false });
151
+ publicReadIdentity = value;
152
+ }
153
+ const result = {
154
+ data, ...queryResponseMetadata(input.udfPath, response),
155
+ projectId: response.headers.get(QUERY_PROJECT_ID_HEADER) ?? publicReadIdentity?.projectShortId,
156
+ ...(publicReadIdentity ? { publicReadIdentity } : {}),
157
+ };
158
+ assertResponseProject(result, opts.projectId);
159
+ fireOnAccessedContent({
160
+ udfPath: input.udfPath, args: input.args, data, response,
161
+ requestContext: input.requestContext, isPreview,
162
+ });
163
+ return result;
164
+ }
165
+ return { request };
166
+ }
167
+ /** Shared consumption boundary, including injected transports and null results. */
168
+ export function assertResponseProject(response, expected) {
169
+ if (!response || typeof response !== 'object' || Array.isArray(response)) {
170
+ throw new StorystickError('The Storystick response has no project identity.', {
171
+ code: 'storystick/QUERY_PROTOCOL', category: 'system', retryable: false,
172
+ });
173
+ }
174
+ const identity = response.publicReadIdentity;
175
+ if (identity !== undefined && !isPublicReadIdentity(identity)) {
176
+ throw new StorystickError('Invalid public read identity.', { code: 'storystick/QUERY_PROTOCOL', category: 'system', retryable: false });
177
+ }
178
+ const actual = response.projectId ?? identity?.projectShortId;
179
+ if (typeof actual !== 'string' || !actual.trim() || actual.length > 1024 ||
180
+ (identity && identity.projectShortId !== actual)) {
181
+ throw new StorystickError('The Storystick response has no consistent project identity.', {
182
+ code: 'storystick/QUERY_PROTOCOL', category: 'system', retryable: false,
183
+ });
184
+ }
185
+ assertProjectMatch(expected, actual);
186
+ }
187
+ /** Check nested SDK transport bindings before the inner transport can observe data. */
188
+ export function assertProjectMatch(expected, actual) {
189
+ if (actual !== expected) {
190
+ throw new StorystickError('The Storystick response belongs to a different project.', {
191
+ code: PROJECT_ID_MISMATCH.code, category: PROJECT_ID_MISMATCH.category,
192
+ recoverable: PROJECT_ID_MISMATCH.recoverable, retryable: PROJECT_ID_MISMATCH.retryable,
193
+ });
194
+ }
195
+ }
196
+ async function fetchTransportResponse(fetchFn, url, init) {
197
+ try {
198
+ return await fetchFn(url, init);
199
+ }
200
+ catch (error) {
201
+ if (init.signal?.aborted ||
202
+ (error instanceof Error && error.name === 'AbortError')) {
203
+ throw error;
204
+ }
205
+ throw new StorystickError('The Storystick gateway is unreachable.', {
206
+ code: 'storystick/QUERY_NETWORK',
207
+ category: 'system',
208
+ recoverable: true,
209
+ retryable: true,
210
+ });
211
+ }
212
+ }
213
+ function queryResponseMetadata(udfPath, response) {
214
+ if (udfPath !== 'queries:queryV2')
215
+ return {};
216
+ const queryHash = response.headers.get('X-Storystick-Query-Hash');
217
+ const readScopeIdentity = response.headers.get('X-Storystick-Read-Scope-Identity');
218
+ const receiptId = response.headers.get(PREVIEW_QUERY_RECEIPT_HEADER);
219
+ return {
220
+ ...(queryHash && readScopeIdentity
221
+ ? { executionIdentity: { queryHash, readScopeIdentity } }
222
+ : {}),
223
+ ...(receiptId ? { previewQueryReceiptId: receiptId } : {}),
224
+ };
225
+ }
226
+ // ─────────────────────────────────────────────
227
+ // Internal helpers (URL build, fetch init, retry decision, error build)
228
+ // ─────────────────────────────────────────────
229
+ function buildUrl(args) {
230
+ const { endpoint, path, method, args: queryArgs, options, isPreview } = args;
231
+ let url = `${endpoint}${path}`;
232
+ if (method !== 'GET')
233
+ return url;
234
+ const params = new URLSearchParams();
235
+ for (const [key, value] of Object.entries(queryArgs)) {
236
+ if (key === 'preview')
237
+ continue; // gateway derives preview from JWT env claim
238
+ if (value === undefined || value === null)
239
+ continue;
240
+ if (value === true) {
241
+ params.set(key, '1');
242
+ }
243
+ else if (value === false) {
244
+ // skip — gateway treats absence as false (matches legacy behavior)
245
+ continue;
246
+ }
247
+ else if (Array.isArray(value)) {
248
+ params.set(key, value.join(','));
249
+ }
250
+ else if (typeof value === 'object') {
251
+ params.set(key, JSON.stringify(value));
252
+ }
253
+ else {
254
+ params.set(key, String(value));
255
+ }
256
+ }
257
+ const locale = normalizeRequestLocale(options?.locale);
258
+ if (locale && !params.has('locale'))
259
+ params.set('locale', locale);
260
+ if (isPreview)
261
+ params.set('preview', '1');
262
+ if ([...params.keys()].length > 0) {
263
+ url += `?${params.toString()}`;
264
+ }
265
+ return url;
266
+ }
267
+ function buildPostBody(args, options, isPreview, includePreview = true) {
268
+ const { preview: _stripped, ...rest } = args;
269
+ void _stripped;
270
+ const locale = normalizeRequestLocale(options?.locale);
271
+ return JSON.stringify({
272
+ ...rest,
273
+ ...(locale && rest.locale === undefined ? { locale } : {}),
274
+ ...(includePreview ? { preview: isPreview } : {}),
275
+ });
276
+ }
277
+ function normalizeRequestLocale(value) {
278
+ if (typeof value !== 'string')
279
+ return undefined;
280
+ const trimmed = value.trim();
281
+ return trimmed.length > 0 ? trimmed : undefined;
282
+ }
283
+ function buildFetchInit(args) {
284
+ const { method, token, isPreview, udfPath, requestContext, options, body, signal, } = args;
285
+ const headers = {
286
+ Authorization: `Bearer ${token}`,
287
+ 'X-Storystick-Client': 'sdk',
288
+ ...(!isPreview ? contentShapeCapabilityHeaders('api', undefined, args.applicationBuildId) : {}),
289
+ };
290
+ if (udfPath === 'queries:queryV2')
291
+ headers[QUERY_HTTP_PROTOCOL_HEADER] = QUERY_HTTP_PROTOCOL;
292
+ if (isPreview)
293
+ headers['X-Storystick-Preview'] = '1';
294
+ if (isPreview &&
295
+ (udfPath === 'queries:queryV2' || udfPath === 'queries:getRouteByPathname') &&
296
+ requestContext?.previewReadContext &&
297
+ requestContext.previewQueryConsumer) {
298
+ headers[PREVIEW_GATEWAY_CONTEXT_HEADER] = encodePreviewGatewayContext({
299
+ version: 2,
300
+ contextId: requestContext.previewReadContext.contextId,
301
+ revision: requestContext.previewReadContext.revision,
302
+ ...(requestContext.previewSessionId ? { sessionId: requestContext.previewSessionId } : {}),
303
+ ...(requestContext.variantId
304
+ ? { variantId: requestContext.variantId }
305
+ : {}),
306
+ ...(requestContext.releaseId
307
+ ? { releaseId: requestContext.releaseId }
308
+ : {}),
309
+ queryConsumer: requestContext.previewQueryConsumer,
310
+ ...(requestContext.previewReadContext.perspective.kind === 'published' ? { published: true } : {}),
311
+ ...(requestContext.previewReadContext.perspective.kind !== 'published' && requestContext.previewLiveQueryOverlayEnabled === true
312
+ ? { liveQueryOverlay: true }
313
+ : {}),
314
+ });
315
+ }
316
+ if (method === 'POST')
317
+ headers['Content-Type'] = 'application/json';
318
+ const init = {
319
+ method,
320
+ headers,
321
+ body,
322
+ };
323
+ if (signal)
324
+ init.signal = signal;
325
+ if (isPreview) {
326
+ // Preview reads must never enter Next's persistent fetch cache.
327
+ // `Cache-Control: no-store` + `cache: 'no-store'` win over any
328
+ // caller-supplied directives by being applied LAST (ADR-091
329
+ // Consequences > preview semantics).
330
+ init.cache = 'no-store';
331
+ headers['Cache-Control'] = 'no-store';
332
+ return init;
333
+ }
334
+ // Production path — honor user-supplied cache and next.* directives.
335
+ if (options?.cache)
336
+ init.cache = options.cache;
337
+ if (options?.deliveryCache === 'bypass') {
338
+ headers['x-storystick-delivery-fresh'] = '1';
339
+ init.cache = 'no-store';
340
+ return init;
341
+ }
342
+ if (options?.next) {
343
+ const next = {};
344
+ if (options.next.revalidate !== undefined)
345
+ next.revalidate = options.next.revalidate;
346
+ if (options.next.tags && options.next.tags.length > 0)
347
+ next.tags = options.next.tags;
348
+ if (Object.keys(next).length > 0)
349
+ init.next = next;
350
+ }
351
+ return init;
352
+ }
353
+ /**
354
+ * Refresh-vs-retry decision per ADR-091 / Tension #5 lock B.
355
+ *
356
+ * Decision tree (codex Lane A finding 2 — canonical metadata wins):
357
+ * - Body parses + carries a canonical `category` field → trust it.
358
+ * Refresh+retry iff `category === 'auth' && retryable === true`.
359
+ * `category === 'auth' && retryable === false` (e.g. ACCESS_DENIED)
360
+ * does NOT trigger refresh — the original error is surfaced.
361
+ * - Body absent / unparseable / no `category` field → legacy
362
+ * status-based fallback for pre-batch-4 deployments. HTTP 401/403
363
+ * means refreshable.
364
+ *
365
+ * Re-clones the response so the body can be re-read by the caller's
366
+ * StorystickError construction path.
367
+ */
368
+ async function shouldRefreshAndRetry(response) {
369
+ if (response.ok)
370
+ return false;
371
+ let canonicalSeen = false;
372
+ try {
373
+ const cloned = response.clone();
374
+ const bodyJson = (await cloned.json().catch(() => null));
375
+ const wrapped = bodyJson?.error;
376
+ const candidate = wrapped ?? bodyJson ?? undefined;
377
+ if (candidate && candidate.category !== undefined) {
378
+ canonicalSeen = true;
379
+ return candidate.category === 'auth' && candidate.retryable === true;
380
+ }
381
+ }
382
+ catch {
383
+ /* canonical-envelope absent — fall through */
384
+ }
385
+ if (canonicalSeen)
386
+ return false;
387
+ // Pre-batch-4 deployments: status-based legacy fallback.
388
+ return response.status === 401 || response.status === 403;
389
+ }
390
+ function errorRecord(value) {
391
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
392
+ ? value : {};
393
+ }
394
+ /** Read-only transport: explicit metadata wins; HTTP remains usable without it. */
395
+ async function buildStorystickError(response) {
396
+ const body = errorRecord(await response.json().catch(() => null));
397
+ const wrapped = errorRecord(body.error);
398
+ const code = typeof wrapped.code === 'string' ? wrapped.code
399
+ : typeof body.code === 'string' ? body.code : `HTTP_${response.status}`;
400
+ const message = typeof wrapped.message === 'string' ? wrapped.message
401
+ : typeof body.message === 'string' ? body.message : response.statusText || 'Request failed';
402
+ const temporary = isTemporaryHttpStatus(response.status);
403
+ const category = typeof wrapped.category === 'string' &&
404
+ ['validation', 'conflict', 'auth', 'system', 'cancel'].includes(wrapped.category)
405
+ ? wrapped.category : undefined;
406
+ return new StorystickError(`${code} — ${message}`, {
407
+ ref: typeof wrapped.ref === 'string' ? wrapped.ref : response.headers.get('X-Storystick-Error-Ref') || undefined,
408
+ code,
409
+ category: category ?? (temporary ? 'system' : undefined),
410
+ recoverable: typeof wrapped.recoverable === 'boolean' ? wrapped.recoverable : undefined,
411
+ retryable: httpFailureRetryable({ operation: 'read', status: response.status,
412
+ retryable: typeof wrapped.retryable === 'boolean' ? wrapped.retryable : undefined }),
413
+ payload: wrapped.payload,
414
+ status: response.status,
415
+ retryAfterMs: parseRetryAfter(response.headers.get('Retry-After'), Date.now()),
416
+ });
417
+ }
418
+ // ─────────────────────────────────────────────
419
+ // onAccessedDocuments tap
420
+ // ─────────────────────────────────────────────
421
+ /**
422
+ * Fire the `onAccessedDocuments` tap with extracted documentIds when
423
+ * the preview path is bound. Fire-and-forget — never blocks, never
424
+ * throws. Production calls do NOT fire the tap; observation is a
425
+ * preview-only telemetry channel (ADR-131 §D3.1b R2b item 16).
426
+ *
427
+ * Gated invariant: callback signature is sync-only by design. If a
428
+ * future adapter wants an async tap, the adapter wraps `Promise.allSettled`
429
+ * inside the sync callback — this layer must never block the request
430
+ * response on telemetry I/O.
431
+ */
432
+ function fireOnAccessedContent(input) {
433
+ if (!input.isPreview)
434
+ return;
435
+ const documentIds = extractAccessedDocumentIds(input.udfPath, input.data);
436
+ const legacyTap = input.requestContext?.onAccessedDocuments;
437
+ if (legacyTap && documentIds.length > 0) {
438
+ try {
439
+ legacyTap(documentIds);
440
+ }
441
+ catch {
442
+ // Same fire-and-forget isolation as the enriched tap below.
443
+ }
444
+ }
445
+ const codeVersion = input.requestContext?.previewCodeVersion;
446
+ const observationId = input.requestContext?.previewObservationId;
447
+ const watermarkHeader = input.response.headers.get('X-Storystick-Preview-Read-Watermark');
448
+ const renderWatermark = watermarkHeader === null ? Number.NaN : Number(watermarkHeader);
449
+ // Amendment-003-style fence: a mixed old/new SDK pair emits no enriched
450
+ // observation. It must never attach an unversioned watermark to a frame.
451
+ if (typeof codeVersion !== 'string' ||
452
+ codeVersion.length === 0 ||
453
+ typeof observationId !== 'string' ||
454
+ observationId.length === 0 ||
455
+ !Number.isFinite(renderWatermark))
456
+ return;
457
+ const receiptId = input.response.headers.get(PREVIEW_QUERY_RECEIPT_HEADER);
458
+ const observation = {
459
+ ...(input.requestContext?.previewReadContext
460
+ ? { readContext: input.requestContext.previewReadContext }
461
+ : {}),
462
+ ...(receiptId && /^pqr_[a-f0-9]{64}$/.test(receiptId) ? { receiptId } : {}),
463
+ documentIds,
464
+ storyTypes: extractAccessedStoryTypes(input.udfPath, input.args, input.data),
465
+ codeVersion,
466
+ observationId,
467
+ renderWatermark,
468
+ };
469
+ attachPreviewReadObservation(input.data, observation);
470
+ const tap = input.requestContext?.onAccessedContent;
471
+ if (!tap)
472
+ return;
473
+ // A zero-document list is still a read dependency on its story type. The
474
+ // first document created for that type must wake the displayed frame.
475
+ if (observation.documentIds.length === 0 &&
476
+ observation.storyTypes.length === 0)
477
+ return;
478
+ try {
479
+ tap(observation);
480
+ }
481
+ catch {
482
+ // Tap implementation threw — swallow. The tap is fire-and-forget
483
+ // by contract; an exception there must never propagate into the
484
+ // RSC render path.
485
+ }
486
+ }
487
+ function extractAccessedStoryTypes(udfPath, args, data) {
488
+ const seen = new Set();
489
+ const add = (value) => {
490
+ if (typeof value === 'string' && value.trim().length > 0) {
491
+ seen.add(value.trim());
492
+ }
493
+ };
494
+ const addFromDocument = (value) => {
495
+ if (value && typeof value === 'object') {
496
+ add(value.storyType);
497
+ }
498
+ };
499
+ switch (udfPath) {
500
+ case 'queries:getRouteByPathname':
501
+ if (Array.isArray(args.storyTypes)) {
502
+ for (const storyType of args.storyTypes)
503
+ add(storyType);
504
+ }
505
+ break;
506
+ case 'queries:queryV2':
507
+ if (args.query && typeof args.query === 'object') {
508
+ const target = args.query.target;
509
+ if (target && typeof target === 'object') {
510
+ add(target.storyType);
511
+ }
512
+ }
513
+ break;
514
+ default:
515
+ break;
516
+ }
517
+ if (Array.isArray(data)) {
518
+ for (const row of data)
519
+ addFromDocument(row);
520
+ }
521
+ else if (data && typeof data === 'object') {
522
+ addFromDocument(data);
523
+ const items = data.data;
524
+ if (Array.isArray(items)) {
525
+ for (const row of items)
526
+ addFromDocument(row);
527
+ }
528
+ const document = data.document;
529
+ addFromDocument(document);
530
+ const documents = data.documents;
531
+ if (Array.isArray(documents)) {
532
+ for (const row of documents)
533
+ addFromDocument(row);
534
+ }
535
+ }
536
+ return [...seen];
537
+ }
538
+ /**
539
+ * Per-udfPath documentId extraction (ADR-131 §D3.1b). Returns a
540
+ * de-duplicated, declared-order list (Set semantics with stable
541
+ * insertion order — JS Map/Set preserve insertion order).
542
+ *
543
+ * Shape rules (mirrors the gateway's response envelopes):
544
+ * - `queries:getRouteByPathname`→ `{ document: { documentId, ... } }`
545
+ * on hit; miss envelopes do not contain a document
546
+ * - `queries:queryV2` → document(s) in the exact result `.data`
547
+ *
548
+ * Unknown udfPath OR a response missing the expected `documentId` shape
549
+ * returns the empty list — the tap is silently inert rather than
550
+ * surfacing a contract violation. The orchestrator's second-preview
551
+ * bundle composition will reconverge via the schema marker path
552
+ * (§D3.1a 3-source union — chrome:true ∪ page-closure ∪ observed).
553
+ */
554
+ function extractAccessedDocumentIds(udfPath, data) {
555
+ const seen = new Set();
556
+ const add = (value) => {
557
+ if (typeof value === 'string' && value.length > 0)
558
+ seen.add(value);
559
+ };
560
+ switch (udfPath) {
561
+ case 'queries:getRouteByPathname': {
562
+ if (data && typeof data === 'object') {
563
+ const doc = data.document;
564
+ if (doc && typeof doc === 'object') {
565
+ add(doc.documentId);
566
+ }
567
+ }
568
+ break;
569
+ }
570
+ case 'queries:queryV2': {
571
+ if (data && typeof data === 'object') {
572
+ const result = data.data;
573
+ for (const row of Array.isArray(result) ? result : [result]) {
574
+ if (row && typeof row === 'object')
575
+ add(row.documentId);
576
+ }
577
+ }
578
+ break;
579
+ }
580
+ default:
581
+ break;
582
+ }
583
+ return Array.from(seen);
584
+ }