@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.
- package/dist/hooks/useBuzzWorkflow.d.ts +22 -0
- package/dist/hooks/useBuzzWorkflow.js +10 -2
- package/dist/hooks/useGoodPurchase.d.ts +13 -0
- package/dist/hooks/useGoodPurchase.js +5 -2
- package/dist/hooks/useTip.d.ts +42 -1
- package/dist/hooks/useTip.js +36 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +12 -0
- package/dist/internal/mockHost.d.ts +57 -5
- package/dist/internal/mockHost.js +75 -0
- package/dist/internal/mockHostIdempotency.d.ts +100 -0
- package/dist/internal/mockHostIdempotency.js +113 -0
- package/dist/internal/mockHostScopes.d.ts +100 -0
- package/dist/internal/mockHostScopes.js +171 -0
- package/dist/transport/transport.d.ts +57 -0
- package/dist/transport/transport.js +76 -0
- package/dist/ui/BlockGate.d.ts +3 -2
- package/dist/ui/BlockGate.js +37 -43
- package/dist/ui/TipButton.d.ts +38 -0
- package/dist/ui/TipButton.js +73 -2
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.js +17 -2
- package/package.json +5 -5
|
@@ -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
|
*
|
package/dist/ui/BlockGate.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
35
|
-
*
|
|
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
|
/**
|
package/dist/ui/BlockGate.js
CHANGED
|
@@ -1,52 +1,45 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect
|
|
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
|
-
*
|
|
9
|
+
* The theme the PAGE is already painted in β never the OS preference.
|
|
10
10
|
*
|
|
11
|
-
* A directly-loaded block has
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
*
|
|
91
|
-
*
|
|
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 =
|
|
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(() => {
|