@civitai/blocks-react 0.61.1 β†’ 0.63.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.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * mockHostIdempotency.ts β€” the dev host's IDEMPOTENCY-KEY FORMAT GATE.
3
+ *
4
+ * THE MOTIVATING FAILURE (2026-10-02). A character-sheet block sent an
5
+ * `idempotencyKey` of the form `sheetId:panelId:nonce`. It passed **201 local
6
+ * tests**, the dev harness and review. Every save in production failed:
7
+ *
8
+ * { "code": "invalid_format", "format": "regex",
9
+ * "pattern": "/^[A-Za-z0-9_-]{1,64}$/",
10
+ * "path": ["idempotencyKey"],
11
+ * "message": "Invalid string: must match pattern /^[A-Za-z0-9_-]{1,64}$/" }
12
+ *
13
+ * `BAD_REQUEST` / httpStatus 400 on path `blocks.submitWorkflow`.
14
+ *
15
+ * πŸ”΄ THE REASON IT GOT THAT FAR IS THIS FILE'S ABSENCE β€” the exact shape of
16
+ * #511's storage-scope gap, one surface over. `createMockHost` had **zero**
17
+ * occurrences of `idempotencyKey`: it accepted the field, forwarded nothing, and
18
+ * validated nothing, so the one submit failure mode that actually ships was the
19
+ * only one the mock could not produce. It models a caught server exception, a
20
+ * disallowed account pool, insufficient Buzz, a fail-rate dice roll and an
21
+ * induced transport failure β€” and not the input validator that runs before any
22
+ * of them.
23
+ *
24
+ * This is the missing arm of an existing simulation, not a new feature.
25
+ *
26
+ * ---
27
+ *
28
+ * WHAT THE SERVER ACTUALLY DOES, and why the refusal has THIS shape.
29
+ *
30
+ * The rule is a zod `.regex()` on the procedure INPUT
31
+ * (`blocks.router.ts:6227`), so it fires **before the procedure body runs** β€”
32
+ * ahead of every scenario, budget and balance decision the handler below would
33
+ * otherwise make. That ordering is modelled deliberately: the gate sits ahead of
34
+ * the message switch, so a malformed key is refused even when the scenario,
35
+ * the balance and the spend cap would all have allowed the submit, because
36
+ * production refuses it then too.
37
+ *
38
+ * A tRPC input rejection reaches the block as a host-synthesised failure
39
+ * snapshot, NOT as a thrown error β€” the reply crosses `postMessage`.
40
+ * `internal/liveHost.ts:298` is the mapping: `errorSnapshot(error)` β†’
41
+ * `{ workflowId: 'failed', status: 'failed', error }`, with **no `cost`**. That
42
+ * is byte-for-byte the shape the disallowed-account branch in `mockHost.ts`
43
+ * already emits, and it is what makes `useBuzzWorkflow().submit` reject with
44
+ * `WorkflowSubmitError` code `'exception'`. Emitting anything else here β€” a
45
+ * priced refusal, a thrown error, a silent drop β€” would model a host that does
46
+ * not exist.
47
+ *
48
+ * πŸ”΄ ONE MESSAGE TYPE TODAY, AND A TABLE ANYWAY. `SUBMIT_WORKFLOW` is the only
49
+ * block→host message in `@civitai/app-sdk`'s protocol carrying an
50
+ * `idempotencyKey` (the tip and good-purchase paths are direct REST POSTs and
51
+ * never traverse the mock host, so they are covered by the hook-boundary guard
52
+ * alone β€” see `resolveIdempotencyKey`). The table is still the right shape: a
53
+ * future money message that gains the field is governed the moment it joins
54
+ * `IDEMPOTENT_MESSAGES`, rather than whenever someone remembers to copy a check
55
+ * into its handler. That is the property #511 bought on the storage side and the
56
+ * reason its gate sits ahead of the switch.
57
+ *
58
+ * πŸ”΄ WHY THIS IS NOT REDUNDANT WITH THE HOOK GUARD. Two independent reachable
59
+ * paths bypass the hooks: a block may drive the transport directly
60
+ * (`getTransport().sendRequest({ type: 'SUBMIT_WORKFLOW', … })`, which the
61
+ * package exports), and the dev harness dispatches hand-built messages. The gate
62
+ * is also the arm that makes the DEV HARNESS and the TEST SUITE go red, which is
63
+ * what the original defect needed and did not have: a hook-side throw protects
64
+ * callers of that hook, while this protects the protocol.
65
+ */
66
+ import { BLOCK_IDEMPOTENCY_KEY_REGEX, blockIdempotencyKeyRejection, } from '@civitai/app-sdk/blocks';
67
+ /**
68
+ * Block→host message types whose payload carries an `idempotencyKey` the host
69
+ * validates. Exported for the ledger test, which fails when the set grows or
70
+ * shrinks β€” so a new money message cannot join the protocol ungated and a
71
+ * retired one cannot leave a dead row behind.
72
+ */
73
+ export const IDEMPOTENT_MESSAGES = ['SUBMIT_WORKFLOW'];
74
+ /** Does this gate govern `type`? */
75
+ export function governsIdempotencyKey(type) {
76
+ return IDEMPOTENT_MESSAGES.includes(type);
77
+ }
78
+ /**
79
+ * The refusal text, modelled on the server's own `invalid_format` message so a
80
+ * developer grepping their production logs for the string finds the dev one too.
81
+ *
82
+ * πŸ”΄ DO NOT LET A BLOCK BRANCH ON THIS STRING and do not render it to a viewer.
83
+ * It is developer-facing: a malformed key is a defect in the block's code, so
84
+ * "please try again" is the wrong copy for it β€” no number of retries fixes it.
85
+ */
86
+ export function idempotencyKeyDeniedMessage(reason) {
87
+ // πŸ”΄ INTERPOLATED FROM THE VENDORED CONSTANT, NEVER RE-TYPED. The first draft
88
+ // of this line spelled the pattern out β€” and the single-source guard
89
+ // (`tests/guards/idempotency-key-rule-single-source.test.mjs`) caught it,
90
+ // which is the whole point of that guard: a second copy of a rule the host
91
+ // owns can only ever drift from it.
92
+ return `Invalid string: must match pattern ${String(BLOCK_IDEMPOTENCY_KEY_REGEX)} (idempotencyKey) β€” ${reason}`;
93
+ }
94
+ /**
95
+ * The reason `payload.idempotencyKey` is unacceptable, or `null` to proceed.
96
+ *
97
+ * πŸ”΄ AN ABSENT KEY IS VALID AND MUST STAY VALID. The field is optional on the
98
+ * wire; `undefined` means "no dedupe", which is the ordinary case for every
99
+ * block that never passes one. Validating absence would refuse almost every
100
+ * submit in the suite β€” and would be a FALSE model, since the host's own schema
101
+ * is `.optional()` on this path.
102
+ *
103
+ * Note the asymmetry the host has and this cannot: the REST submit endpoint
104
+ * (`submit.ts:135`) makes the field REQUIRED. The mock serves the postMessage
105
+ * bridge, which is the `.optional()` arm, so absence is correct here.
106
+ */
107
+ export function idempotencyKeyRefusal(payload) {
108
+ const key = payload?.idempotencyKey;
109
+ if (key === undefined)
110
+ return null;
111
+ return blockIdempotencyKeyRejection(key);
112
+ }
113
+ //# sourceMappingURL=mockHostIdempotency.js.map
@@ -0,0 +1,100 @@
1
+ /**
2
+ * mockHostScopes.ts β€” the dev host's STORAGE SCOPE GATE.
3
+ *
4
+ * THE MOTIVATING FAILURE (2026-10-01). An app was built from the documented
5
+ * onboarding prompt, shipped `useAppStorage()` for every save, and declared only
6
+ * `ai:write:budgeted` in its manifest. It passed **198 unit tests, the dev
7
+ * harness, `civitai app validate`, and a full submit** β€” then every save in
8
+ * production failed:
9
+ *
10
+ * "message": "storage set requires the apps:storage:write scope",
11
+ * "code": -32003,
12
+ * "data": { "code": "FORBIDDEN", "httpStatus": 403, "path": "apps.storage.set" }
13
+ *
14
+ * which the viewer saw as *"Saving failed for an unknown reason… try again"*.
15
+ *
16
+ * πŸ”΄ THE REASON IT GOT THAT FAR IS THIS FILE'S ABSENCE. `createMockHost` served
17
+ * storage unconditionally β€” its own options doc said so in as many words
18
+ * ("`APP_STORAGE_*` is answered either way (the mock host always serves storage
19
+ * now)") β€” so the one failure mode that actually ships was the only storage
20
+ * failure mode the mock could not produce. It modelled the per-value cap, both
21
+ * per-viewer budgets, the row limit and an induced transport failure, and not
22
+ * the scope.
23
+ *
24
+ * So this is not a new feature so much as the missing arm of an existing
25
+ * simulation: the dev host already models `ai:write:budgeted` (it has a flag,
26
+ * a consent round-trip and an un-grantable case). Storage had nothing.
27
+ *
28
+ * ---
29
+ *
30
+ * WHAT THE SERVER ACTUALLY DOES, and what is inferred here.
31
+ *
32
+ * `BLOCK_SCOPES` in `@civitai/app-sdk` states the mechanism: the storage scopes
33
+ * "have no OAuth bit … the server gates them by presence in the block's
34
+ * APPROVED SCOPE SET, not a bitmask". Presence is therefore the whole test, and
35
+ * it is what {@link requiredStorageScope} models.
36
+ *
37
+ * πŸ”΄ ONE ROW IS MEASURED; THE REST ARE INFERRED FROM THE SCOPE NAMES. The
38
+ * incident above is direct evidence for exactly one pair β€”
39
+ * `apps.storage.set` β†’ `apps:storage:write` β€” because the server named both in
40
+ * its own refusal. Every other row below reads a `:read` scope onto a read op
41
+ * and a `:write` scope onto a write op, which is the only split the names admit
42
+ * but is still an inference about another service. Nothing in this repository
43
+ * can verify it: there is no op→scope table in `@civitai/app-sdk`, none in this
44
+ * package, and the server's own table is not vendored.
45
+ *
46
+ * The consequence matters because enforcement is ON by default: a row that is
47
+ * WRONG fails a CORRECT app's suite. If that happens, the row is the suspect β€”
48
+ * not the app. Fix the row and say what the server did instead.
49
+ *
50
+ * `SHARED_REPORT` is the row to doubt first. It is mapped to
51
+ * `apps:storage:shared:write` because `useSharedStorage().report()` files a row
52
+ * in the shared store, so it is a write by construction β€” but a server is also
53
+ * free to treat an abuse report as a moderation path outside the store's own
54
+ * gate, in which case this row over-gates and should be removed rather than
55
+ * weakened.
56
+ */
57
+ /** Every message type this gate governs. Exported for the ledger test. */
58
+ export declare function gatedStorageMessages(): string[];
59
+ /**
60
+ * The scope `type` needs, or `null` when this gate does not govern it.
61
+ *
62
+ * `null` is the ordinary answer — the overwhelming majority of block→host
63
+ * messages are not storage.
64
+ */
65
+ export declare function requiredStorageScope(type: string): string | null;
66
+ /**
67
+ * The refusal text. Modelled on the server's own prose for the one case it was
68
+ * observed emitting: *"storage set requires the apps:storage:write scope"*.
69
+ *
70
+ * πŸ”΄ DO NOT LET A BLOCK BRANCH ON THIS STRING, and do not render it to a viewer.
71
+ * `@civitai/app-sdk`'s `appStorageErrors.ts` is explicit that host refusal prose
72
+ * is "not localized, not written for an end user, and free to change", and that
73
+ * an authorization failure deliberately classifies as `null` through
74
+ * `classifyAppStorageError` β€” which is exactly what this message does, matching
75
+ * production rather than inventing a classification the real host does not send.
76
+ * That `null` arm is also why *"please try again"* is the wrong copy for it: a
77
+ * missing scope is a manifest defect and no number of retries fixes it.
78
+ */
79
+ export declare function storageScopeDeniedMessage(type: string, scope: string): string;
80
+ /**
81
+ * The reply payload that refuses `type`, type-correct for that reply's own
82
+ * contract.
83
+ *
84
+ * πŸ”΄ EVERY STORAGE REPLY CARRIES `error?`, AND A NON-EMPTY `error` IS THE REJECT
85
+ * SIGNAL β€” `@civitai/app-sdk`'s messages module calls it "the
86
+ * `APP_STORAGE_GET_RESULT` value-or-error convention: consumers treat a
87
+ * non-empty `error` as the failure signal". So a refusal does not need a new
88
+ * wire field, a new constant, or an `@civitai/app-sdk` change β€” which also
89
+ * means it needs no peer-range bump, the hazard that once left 27 of 43 test
90
+ * files collecting zero tests in this very package.
91
+ *
92
+ * The DATA fields are still required by each reply's type, so each arm supplies
93
+ * an empty-but-valid one rather than omitting it: a reply that fails its own
94
+ * contract risks being dropped by the host-message validator, which would
95
+ * present as a HANG rather than a refusal.
96
+ */
97
+ export declare function storageScopeDeniedPayload(type: string, requestId: string | undefined, error: string): Record<string, unknown>;
98
+ /** The reply type for a block→host storage message. Uniform across the family. */
99
+ export declare function storageResultType(type: string): string;
100
+ //# sourceMappingURL=mockHostScopes.d.ts.map
@@ -0,0 +1,171 @@
1
+ /**
2
+ * mockHostScopes.ts β€” the dev host's STORAGE SCOPE GATE.
3
+ *
4
+ * THE MOTIVATING FAILURE (2026-10-01). An app was built from the documented
5
+ * onboarding prompt, shipped `useAppStorage()` for every save, and declared only
6
+ * `ai:write:budgeted` in its manifest. It passed **198 unit tests, the dev
7
+ * harness, `civitai app validate`, and a full submit** β€” then every save in
8
+ * production failed:
9
+ *
10
+ * "message": "storage set requires the apps:storage:write scope",
11
+ * "code": -32003,
12
+ * "data": { "code": "FORBIDDEN", "httpStatus": 403, "path": "apps.storage.set" }
13
+ *
14
+ * which the viewer saw as *"Saving failed for an unknown reason… try again"*.
15
+ *
16
+ * πŸ”΄ THE REASON IT GOT THAT FAR IS THIS FILE'S ABSENCE. `createMockHost` served
17
+ * storage unconditionally β€” its own options doc said so in as many words
18
+ * ("`APP_STORAGE_*` is answered either way (the mock host always serves storage
19
+ * now)") β€” so the one failure mode that actually ships was the only storage
20
+ * failure mode the mock could not produce. It modelled the per-value cap, both
21
+ * per-viewer budgets, the row limit and an induced transport failure, and not
22
+ * the scope.
23
+ *
24
+ * So this is not a new feature so much as the missing arm of an existing
25
+ * simulation: the dev host already models `ai:write:budgeted` (it has a flag,
26
+ * a consent round-trip and an un-grantable case). Storage had nothing.
27
+ *
28
+ * ---
29
+ *
30
+ * WHAT THE SERVER ACTUALLY DOES, and what is inferred here.
31
+ *
32
+ * `BLOCK_SCOPES` in `@civitai/app-sdk` states the mechanism: the storage scopes
33
+ * "have no OAuth bit … the server gates them by presence in the block's
34
+ * APPROVED SCOPE SET, not a bitmask". Presence is therefore the whole test, and
35
+ * it is what {@link requiredStorageScope} models.
36
+ *
37
+ * πŸ”΄ ONE ROW IS MEASURED; THE REST ARE INFERRED FROM THE SCOPE NAMES. The
38
+ * incident above is direct evidence for exactly one pair β€”
39
+ * `apps.storage.set` β†’ `apps:storage:write` β€” because the server named both in
40
+ * its own refusal. Every other row below reads a `:read` scope onto a read op
41
+ * and a `:write` scope onto a write op, which is the only split the names admit
42
+ * but is still an inference about another service. Nothing in this repository
43
+ * can verify it: there is no op→scope table in `@civitai/app-sdk`, none in this
44
+ * package, and the server's own table is not vendored.
45
+ *
46
+ * The consequence matters because enforcement is ON by default: a row that is
47
+ * WRONG fails a CORRECT app's suite. If that happens, the row is the suspect β€”
48
+ * not the app. Fix the row and say what the server did instead.
49
+ *
50
+ * `SHARED_REPORT` is the row to doubt first. It is mapped to
51
+ * `apps:storage:shared:write` because `useSharedStorage().report()` files a row
52
+ * in the shared store, so it is a write by construction β€” but a server is also
53
+ * free to treat an abuse report as a moderation path outside the store's own
54
+ * gate, in which case this row over-gates and should be removed rather than
55
+ * weakened.
56
+ */
57
+ import { BLOCK_SCOPES } from '@civitai/app-sdk/blocks';
58
+ /**
59
+ * Block→host message type → the scope the server requires to answer it.
60
+ *
61
+ * Only storage surfaces appear here. The money path keeps its own flag
62
+ * (`consentGranted`) in `mockHost.ts`, deliberately: `buzzBudget` is conditional
63
+ * on it and `setScenario` can toggle it mid-session, so folding it in here would
64
+ * give one scope two sources of truth.
65
+ */
66
+ const STORAGE_SCOPE_BY_MESSAGE = {
67
+ // Per-app, per-viewer KV store.
68
+ APP_STORAGE_GET: BLOCK_SCOPES.APPS_STORAGE_READ,
69
+ APP_STORAGE_LIST: BLOCK_SCOPES.APPS_STORAGE_READ,
70
+ APP_STORAGE_QUOTA: BLOCK_SCOPES.APPS_STORAGE_READ,
71
+ APP_STORAGE_SET: BLOCK_SCOPES.APPS_STORAGE_WRITE, // ← the MEASURED row
72
+ APP_STORAGE_DELETE: BLOCK_SCOPES.APPS_STORAGE_WRITE,
73
+ // Shared, cross-user store.
74
+ SHARED_LIST: BLOCK_SCOPES.APPS_STORAGE_SHARED_READ,
75
+ SHARED_GET: BLOCK_SCOPES.APPS_STORAGE_SHARED_READ,
76
+ SHARED_GET_COUNT: BLOCK_SCOPES.APPS_STORAGE_SHARED_READ,
77
+ SHARED_GET_COUNTS: BLOCK_SCOPES.APPS_STORAGE_SHARED_READ,
78
+ SHARED_APPEND: BLOCK_SCOPES.APPS_STORAGE_SHARED_WRITE,
79
+ SHARED_VOTE: BLOCK_SCOPES.APPS_STORAGE_SHARED_WRITE,
80
+ SHARED_UNVOTE: BLOCK_SCOPES.APPS_STORAGE_SHARED_WRITE,
81
+ SHARED_WITHDRAW: BLOCK_SCOPES.APPS_STORAGE_SHARED_WRITE,
82
+ SHARED_UPDATE: BLOCK_SCOPES.APPS_STORAGE_SHARED_WRITE,
83
+ SHARED_REPORT: BLOCK_SCOPES.APPS_STORAGE_SHARED_WRITE, // ← doubt this one first
84
+ };
85
+ /** Every message type this gate governs. Exported for the ledger test. */
86
+ export function gatedStorageMessages() {
87
+ return Object.keys(STORAGE_SCOPE_BY_MESSAGE).sort();
88
+ }
89
+ /**
90
+ * The scope `type` needs, or `null` when this gate does not govern it.
91
+ *
92
+ * `null` is the ordinary answer — the overwhelming majority of block→host
93
+ * messages are not storage.
94
+ */
95
+ export function requiredStorageScope(type) {
96
+ return STORAGE_SCOPE_BY_MESSAGE[type] ?? null;
97
+ }
98
+ /**
99
+ * The refusal text. Modelled on the server's own prose for the one case it was
100
+ * observed emitting: *"storage set requires the apps:storage:write scope"*.
101
+ *
102
+ * πŸ”΄ DO NOT LET A BLOCK BRANCH ON THIS STRING, and do not render it to a viewer.
103
+ * `@civitai/app-sdk`'s `appStorageErrors.ts` is explicit that host refusal prose
104
+ * is "not localized, not written for an end user, and free to change", and that
105
+ * an authorization failure deliberately classifies as `null` through
106
+ * `classifyAppStorageError` β€” which is exactly what this message does, matching
107
+ * production rather than inventing a classification the real host does not send.
108
+ * That `null` arm is also why *"please try again"* is the wrong copy for it: a
109
+ * missing scope is a manifest defect and no number of retries fixes it.
110
+ */
111
+ export function storageScopeDeniedMessage(type, scope) {
112
+ return `${verbFor(type)} requires the ${scope} scope`;
113
+ }
114
+ /**
115
+ * The server names the OPERATION, not the message type, in its refusal
116
+ * (`storage set …`, on path `apps.storage.set`). Mirror that so a developer
117
+ * grepping their logs for the production string finds the dev one too.
118
+ */
119
+ function verbFor(type) {
120
+ const shared = type.startsWith('SHARED_');
121
+ const op = type
122
+ .replace(/^APP_STORAGE_/, '')
123
+ .replace(/^SHARED_/, '')
124
+ .toLowerCase()
125
+ .replace(/_/g, ' ');
126
+ return shared ? `shared storage ${op}` : `storage ${op}`;
127
+ }
128
+ /**
129
+ * The reply payload that refuses `type`, type-correct for that reply's own
130
+ * contract.
131
+ *
132
+ * πŸ”΄ EVERY STORAGE REPLY CARRIES `error?`, AND A NON-EMPTY `error` IS THE REJECT
133
+ * SIGNAL β€” `@civitai/app-sdk`'s messages module calls it "the
134
+ * `APP_STORAGE_GET_RESULT` value-or-error convention: consumers treat a
135
+ * non-empty `error` as the failure signal". So a refusal does not need a new
136
+ * wire field, a new constant, or an `@civitai/app-sdk` change β€” which also
137
+ * means it needs no peer-range bump, the hazard that once left 27 of 43 test
138
+ * files collecting zero tests in this very package.
139
+ *
140
+ * The DATA fields are still required by each reply's type, so each arm supplies
141
+ * an empty-but-valid one rather than omitting it: a reply that fails its own
142
+ * contract risks being dropped by the host-message validator, which would
143
+ * present as a HANG rather than a refusal.
144
+ */
145
+ export function storageScopeDeniedPayload(type, requestId, error) {
146
+ switch (type) {
147
+ // ---- reads: data field + error ----
148
+ case 'APP_STORAGE_GET':
149
+ return { requestId, value: null, error };
150
+ case 'APP_STORAGE_LIST':
151
+ return { requestId, keys: [], error };
152
+ case 'APP_STORAGE_QUOTA':
153
+ return { requestId, usedBytes: 0, rowCount: 0, limitBytes: 0, limitRows: 0, error };
154
+ case 'SHARED_LIST':
155
+ return { requestId, items: [], error };
156
+ case 'SHARED_GET':
157
+ return { requestId, item: null, error };
158
+ case 'SHARED_GET_COUNT':
159
+ return { requestId, count: 0, error };
160
+ case 'SHARED_GET_COUNTS':
161
+ return { requestId, counts: {}, error };
162
+ // ---- writes: ok:false + error ----
163
+ default:
164
+ return { requestId, ok: false, error };
165
+ }
166
+ }
167
+ /** The reply type for a block→host storage message. Uniform across the family. */
168
+ export function storageResultType(type) {
169
+ return `${type}_RESULT`;
170
+ }
171
+ //# sourceMappingURL=mockHostScopes.js.map
@@ -232,6 +232,63 @@ export declare function nextRequestId(): string;
232
232
  * string where it is unavailable (older webviews / non-secure contexts / test).
233
233
  */
234
234
  export declare function generateIdempotencyKey(): string;
235
+ /**
236
+ * A CALLER-SUPPLIED `idempotencyKey` that the host would reject. Thrown by
237
+ * `useBuzzWorkflow().submit`, `useTip().tip` and `useGoodPurchase().purchase`
238
+ * **before anything is sent**.
239
+ *
240
+ * πŸ”΄ NOTHING WAS SENT AND NOTHING WAS SPENT β€” that is the whole reason this is a
241
+ * DISTINCT class rather than one of the existing money-path errors. Every other
242
+ * rejection on these hooks is money-AMBIGUOUS by design:
243
+ * `WorkflowSubmitError`'s own docs say its `'exception'` arm covers "a lost
244
+ * response or an in-progress idempotency conflict", and the abort/timeout
245
+ * rejections say in as many words that "the charge may or may not have landed".
246
+ * Reusing any of those for a key refused at the boundary would hand the caller
247
+ * an error whose documented contract is strictly weaker than the truth, and the
248
+ * repo's money rule runs the other way: never tell a caller money did not move
249
+ * unless you know it. Here we do know, structurally β€” the request never left the
250
+ * iframe β€” so the caller gets a type that says so.
251
+ *
252
+ * Two further reasons it is not a `WorkflowSubmitError`:
253
+ * - that class REQUIRES a `snapshot`, which is the host's reply. There is no
254
+ * reply. Synthesising one would be inventing a host message the host never
255
+ * sent, and `workflowId: 'failed'` specifically means "the host had no
256
+ * workflow to report" β€” a claim about the host we are not entitled to make.
257
+ * - it is not a runtime OUTCOME at all. It is a defect in the calling block's
258
+ * own code, in the same family as the existing
259
+ * `'host origin not established yet'` throw: no retry fixes it, and the fix
260
+ * is a source change.
261
+ *
262
+ * πŸ”΄ THE KEY IS REFUSED, NEVER REWRITTEN. See `@civitai/app-sdk/blocks`'
263
+ * `idempotency.ts`: sanitising a caller's key would break the identity the key
264
+ * exists to carry β€” two distinct logical submits could collapse onto one slot,
265
+ * or a retry could be normalised differently from its first attempt and mint a
266
+ * SECOND reservation. A loud refusal is the only money-safe answer.
267
+ *
268
+ * Branch on `instanceof` or `name === 'InvalidIdempotencyKeyError'`; the
269
+ * `message` wording is developer-facing and is not a contract. Do NOT render it
270
+ * to a viewer β€” they cannot act on it.
271
+ */
272
+ export declare class InvalidIdempotencyKeyError extends Error {
273
+ /** The rejected value, verbatim, for logging. The block's own construction. */
274
+ readonly idempotencyKey: unknown;
275
+ /** Which hook refused it, e.g. `'useBuzzWorkflow.submit'`. */
276
+ readonly source: string;
277
+ constructor(source: string, idempotencyKey: unknown, message: string);
278
+ }
279
+ /**
280
+ * Refuse a caller-supplied key, or mint one when the caller supplied none.
281
+ *
282
+ * πŸ”΄ THE `undefined` CASE IS NOT VALIDATED, AND MUST NOT BE. `idempotencyKey` is
283
+ * optional on all three hooks; absent means "mint one for me", and the generated
284
+ * key conforms by construction. Routing `undefined` into the predicate would
285
+ * turn every ordinary keyless call into a refusal.
286
+ *
287
+ * Ordering is load-bearing: validation happens BEFORE the generator is consulted
288
+ * and before any transport/fetch work, so a bad key costs nothing and cannot
289
+ * race a send.
290
+ */
291
+ export declare function resolveIdempotencyKey(source: string, supplied: string | undefined): string;
235
292
  /**
236
293
  * A request that got NO REPLY before its timeout elapsed.
237
294
  *
@@ -1,3 +1,4 @@
1
+ import { BLOCK_IDEMPOTENCY_KEY_MAX_LENGTH, BLOCK_IDEMPOTENCY_KEY_REGEX, blockIdempotencyKeyRejection, } from '@civitai/app-sdk/blocks';
1
2
  /**
2
3
  * Type-safe wrapper around `transport.sendRequest`. Hooks always go through
3
4
  * this so the response payload narrows based on `responseType`.
@@ -188,6 +189,81 @@ export function generateIdempotencyKey() {
188
189
  .toString(36)
189
190
  .slice(2, 12)}`;
190
191
  }
192
+ /**
193
+ * A CALLER-SUPPLIED `idempotencyKey` that the host would reject. Thrown by
194
+ * `useBuzzWorkflow().submit`, `useTip().tip` and `useGoodPurchase().purchase`
195
+ * **before anything is sent**.
196
+ *
197
+ * πŸ”΄ NOTHING WAS SENT AND NOTHING WAS SPENT β€” that is the whole reason this is a
198
+ * DISTINCT class rather than one of the existing money-path errors. Every other
199
+ * rejection on these hooks is money-AMBIGUOUS by design:
200
+ * `WorkflowSubmitError`'s own docs say its `'exception'` arm covers "a lost
201
+ * response or an in-progress idempotency conflict", and the abort/timeout
202
+ * rejections say in as many words that "the charge may or may not have landed".
203
+ * Reusing any of those for a key refused at the boundary would hand the caller
204
+ * an error whose documented contract is strictly weaker than the truth, and the
205
+ * repo's money rule runs the other way: never tell a caller money did not move
206
+ * unless you know it. Here we do know, structurally β€” the request never left the
207
+ * iframe β€” so the caller gets a type that says so.
208
+ *
209
+ * Two further reasons it is not a `WorkflowSubmitError`:
210
+ * - that class REQUIRES a `snapshot`, which is the host's reply. There is no
211
+ * reply. Synthesising one would be inventing a host message the host never
212
+ * sent, and `workflowId: 'failed'` specifically means "the host had no
213
+ * workflow to report" β€” a claim about the host we are not entitled to make.
214
+ * - it is not a runtime OUTCOME at all. It is a defect in the calling block's
215
+ * own code, in the same family as the existing
216
+ * `'host origin not established yet'` throw: no retry fixes it, and the fix
217
+ * is a source change.
218
+ *
219
+ * πŸ”΄ THE KEY IS REFUSED, NEVER REWRITTEN. See `@civitai/app-sdk/blocks`'
220
+ * `idempotency.ts`: sanitising a caller's key would break the identity the key
221
+ * exists to carry β€” two distinct logical submits could collapse onto one slot,
222
+ * or a retry could be normalised differently from its first attempt and mint a
223
+ * SECOND reservation. A loud refusal is the only money-safe answer.
224
+ *
225
+ * Branch on `instanceof` or `name === 'InvalidIdempotencyKeyError'`; the
226
+ * `message` wording is developer-facing and is not a contract. Do NOT render it
227
+ * to a viewer β€” they cannot act on it.
228
+ */
229
+ export class InvalidIdempotencyKeyError extends Error {
230
+ /** The rejected value, verbatim, for logging. The block's own construction. */
231
+ idempotencyKey;
232
+ /** Which hook refused it, e.g. `'useBuzzWorkflow.submit'`. */
233
+ source;
234
+ constructor(source, idempotencyKey, message) {
235
+ super(message);
236
+ this.name = 'InvalidIdempotencyKeyError';
237
+ this.source = source;
238
+ this.idempotencyKey = idempotencyKey;
239
+ }
240
+ }
241
+ /**
242
+ * Refuse a caller-supplied key, or mint one when the caller supplied none.
243
+ *
244
+ * πŸ”΄ THE `undefined` CASE IS NOT VALIDATED, AND MUST NOT BE. `idempotencyKey` is
245
+ * optional on all three hooks; absent means "mint one for me", and the generated
246
+ * key conforms by construction. Routing `undefined` into the predicate would
247
+ * turn every ordinary keyless call into a refusal.
248
+ *
249
+ * Ordering is load-bearing: validation happens BEFORE the generator is consulted
250
+ * and before any transport/fetch work, so a bad key costs nothing and cannot
251
+ * race a send.
252
+ */
253
+ export function resolveIdempotencyKey(source, supplied) {
254
+ if (supplied === undefined)
255
+ return generateIdempotencyKey();
256
+ const why = blockIdempotencyKeyRejection(supplied);
257
+ if (why !== null) {
258
+ throw new InvalidIdempotencyKeyError(source, supplied, `${source}: refusing to send a malformed idempotencyKey β€” ${why}. ` +
259
+ `Nothing was sent and nothing was spent. The host requires ` +
260
+ `${String(BLOCK_IDEMPOTENCY_KEY_REGEX)} and rejects anything else with a 400; ` +
261
+ `the key is NOT sanitised for you, because rewriting an idempotency key would ` +
262
+ `break the identity it exists to carry. Compose the key from letters, digits, ` +
263
+ `underscore and hyphen only, at most ${BLOCK_IDEMPOTENCY_KEY_MAX_LENGTH} characters.`);
264
+ }
265
+ return supplied;
266
+ }
191
267
  /**
192
268
  * A request that got NO REPLY before its timeout elapsed.
193
269
  *
@@ -31,8 +31,9 @@ export interface DirectLoadFallbackProps {
31
31
  * "waiting for the Civitai host" card with a dev hint β€” NEVER a broken
32
32
  * `apps/run/localhost` link.
33
33
  *
34
- * Theme-aware via `prefers-color-scheme` (there is no host `theme` on a direct
35
- * load) and styled with the `/ui` pack's tokens.
34
+ * Themed from the PAGE β€” `data-theme` on `<html>`, dark when absent β€” never
35
+ * from the OS preference (see {@link readDocumentTheme}), and styled with the
36
+ * `/ui` pack's tokens.
36
37
  */
37
38
  export declare function DirectLoadFallback({ hostname, autoRedirectMs, }: DirectLoadFallbackProps): React.JSX.Element;
38
39
  /**
@@ -1,52 +1,45 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
2
+ import { useEffect } from 'react';
3
3
  import { hostToRunUrl } from '../transport/directLoad.js';
4
4
  import { useDirectLoad } from '../hooks/useDirectLoad.js';
5
5
  import { Card } from './Card.js';
6
6
  import { Stack } from './Stack.js';
7
7
  import { useBlocksStyles } from './styles.js';
8
8
  /**
9
- * Read the OS/browser color-scheme preference, live.
9
+ * The theme the PAGE is already painted in β€” never the OS preference.
10
10
  *
11
- * A directly-loaded block has NO `BLOCK_INIT`, so there is no host `theme` to
12
- * set `data-theme` from (the usual gotcha-#60 path). We fall back to
13
- * `prefers-color-scheme` so the fallback card still themes correctly in light
14
- * AND dark. Guarded for SSR / engines without `matchMedia`.
11
+ * A directly-loaded block has no host: no `BLOCK_INIT`, no `THEME_CHANGE`. The
12
+ * only theme signal that can reach this card is the one the document itself
13
+ * booted with β€” `data-theme` on `<html>`, which the scaffolded `index.html`
14
+ * sets pre-paint from the host fragment (`#civitai-block=v1&theme=…`). So the
15
+ * card follows the page instead of second-guessing it, and a block with no
16
+ * fragment boots dark like every other Civitai surface.
17
+ *
18
+ * `'light'` is the ONLY value that buys light β€” exactly the rule the pre-paint
19
+ * script applies. Absent, empty, `'auto'`, a typo, or no DOM at all (SSR) are
20
+ * all dark.
21
+ *
22
+ * Reading `prefers-color-scheme` here was the defect: on a light-OS machine it
23
+ * painted a LIGHT card on a deliberately DARK page.
24
+ *
25
+ * Setting the attribute explicitly, rather than inheriting it, keeps this card's
26
+ * theme a decision of this component rather than of whatever is above it.
27
+ *
28
+ * ⚠️ It used to be load-bearing for a stronger reason that no longer holds:
29
+ * `@civitai/theme` shipped
30
+ * `@media (prefers-color-scheme: dark) { :root:not([data-theme]) { … } }`, so a
31
+ * document carrying no `data-theme` handed its tokens back to the OS and only an
32
+ * explicit attribute could stop it. Since `@civitai/theme@0.5.0` the base is
33
+ * dark and that at-rule is gone, so inheriting would reach the same answer on a
34
+ * direct-load page. One real difference survives and is why this stays: the read
35
+ * below is `document.documentElement`, whereas inheritance takes the NEAREST
36
+ * `[data-theme]` ancestor. Those coincide on a direct load β€” no host, no wrapper
37
+ * β€” but that is a property of the deployment, not of this code.
15
38
  */
16
- function usePrefersColorScheme() {
17
- const [dark, setDark] = useState(() => {
18
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
19
- return false;
20
- try {
21
- return window.matchMedia('(prefers-color-scheme: dark)').matches;
22
- }
23
- catch {
24
- return false;
25
- }
26
- });
27
- useEffect(() => {
28
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
29
- return;
30
- let mql;
31
- try {
32
- mql = window.matchMedia('(prefers-color-scheme: dark)');
33
- }
34
- catch {
35
- return;
36
- }
37
- const onChange = (e) => setDark(e.matches);
38
- // Modern browsers: addEventListener. Older Safari: addListener.
39
- if (typeof mql.addEventListener === 'function') {
40
- mql.addEventListener('change', onChange);
41
- return () => mql.removeEventListener('change', onChange);
42
- }
43
- if (typeof mql.addListener === 'function') {
44
- mql.addListener(onChange);
45
- return () => mql.removeListener(onChange);
46
- }
47
- return;
48
- }, []);
49
- return dark ? 'dark' : 'light';
39
+ function readDocumentTheme() {
40
+ if (typeof document === 'undefined')
41
+ return 'dark';
42
+ return document.documentElement?.dataset?.theme === 'light' ? 'light' : 'dark';
50
43
  }
51
44
  const wrapperStyle = {
52
45
  minHeight: '100%',
@@ -87,12 +80,13 @@ const bodyStyle = {
87
80
  * "waiting for the Civitai host" card with a dev hint β€” NEVER a broken
88
81
  * `apps/run/localhost` link.
89
82
  *
90
- * Theme-aware via `prefers-color-scheme` (there is no host `theme` on a direct
91
- * load) and styled with the `/ui` pack's tokens.
83
+ * Themed from the PAGE β€” `data-theme` on `<html>`, dark when absent β€” never
84
+ * from the OS preference (see {@link readDocumentTheme}), and styled with the
85
+ * `/ui` pack's tokens.
92
86
  */
93
87
  export function DirectLoadFallback({ hostname, autoRedirectMs, }) {
94
88
  useBlocksStyles();
95
- const theme = usePrefersColorScheme();
89
+ const theme = readDocumentTheme();
96
90
  const resolvedHost = hostname ?? (typeof window !== 'undefined' ? window.location?.hostname : undefined);
97
91
  const runUrl = hostToRunUrl(resolvedHost);
98
92
  useEffect(() => {