@civitai/blocks-react 0.61.1 → 0.62.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.
@@ -599,18 +599,70 @@ export interface MockHostOptions {
599
599
  * {@link MockHost.setScenario}.
600
600
  */
601
601
  disallowedAccountTypes?: BuzzAccountType[];
602
+ /**
603
+ * The scopes the app's `block.manifest.json` DECLARES — the set the real
604
+ * host's token mint draws from.
605
+ *
606
+ * 🔴 **STORAGE IS GATED ON THIS, AND THE DEFAULT IS EMPTY.** A storage op whose
607
+ * scope is not in here is refused exactly as the server refuses it, because the
608
+ * server's test is presence in the block's approved scope set (see
609
+ * `BLOCK_SCOPES` in `@civitai/app-sdk`) and an undeclared scope is never
610
+ * approved. Omit this and every `APP_STORAGE_*` / `SHARED_*` call fails.
611
+ *
612
+ * That is a DELIBERATE BREAKING DEFAULT. Until this existed the mock host
613
+ * served storage unconditionally, so an app that forgot the scopes passed its
614
+ * whole suite and the dev harness and then failed every save in production —
615
+ * the one storage failure mode that actually ships was the only one the mock
616
+ * could not produce. A default of "permissive" would have left that true for
617
+ * every app that did not opt in, i.e. precisely the apps that did not know the
618
+ * scopes existed.
619
+ *
620
+ * Pass what your manifest declares — ideally by importing your own
621
+ * `block.manifest.json` as `manifest`, so the two cannot drift:
622
+ *
623
+ * ```ts
624
+ * createMockHost({ declaredScopes: manifest.scopes });
625
+ * ```
626
+ *
627
+ * ⚠️ The `import` line is described rather than shown ON PURPOSE, and please
628
+ * do not helpfully add it back. `tests/guards/blocks-react-entry-directory-names.test.mjs`
629
+ * extracts import specifiers with a raw regex over the whole file —
630
+ * `/\bfrom\s*['"]([^'"]+)['"]/g`, comments included — so a `from '…'` inside a
631
+ * doc comment is read as a real edge and resolved against THIS file's
632
+ * directory. A relative path to a consumer's manifest does not exist from
633
+ * here, and the guard fails with `unresolvable specifier`. Measured: it went
634
+ * red on all five `Starter (…)` matrix legs.
635
+ *
636
+ * A test that only exercises storage mechanics (quota, caps, row limits) and
637
+ * does not care about authorization should declare the storage scopes
638
+ * explicitly rather than reach for a permissive flag — there is none, on
639
+ * purpose.
640
+ *
641
+ * ⚠️ Scopes OTHER than storage are not read from here yet. `ai:write:budgeted`
642
+ * keeps its own `consentGranted` flag, because `buzzBudget` is conditional on
643
+ * it and `setScenario` can toggle it mid-session; giving one scope two sources
644
+ * of truth is how they drift.
645
+ */
646
+ declaredScopes?: string[];
602
647
  /**
603
648
  * STORAGE scenario: in-memory KV backend (seed / quota / failNext). See
604
649
  * {@link MockStorageScenario}. When omitted, the store starts EMPTY with the
605
- * v0 defaults — `APP_STORAGE_*` is answered either way (the mock host always
606
- * serves storage now).
650
+ * v0 defaults.
651
+ *
652
+ * ⚠️ This governs the BACKEND, not authorization. Storage is additionally
653
+ * gated on {@link MockHostOptions.declaredScopes}, which defaults to empty —
654
+ * so a scenario alone no longer makes storage answer. (It used to: this doc
655
+ * said `APP_STORAGE_*` was "answered either way", and that was the defect.)
607
656
  */
608
657
  storage?: MockStorageScenario;
609
658
  /**
610
659
  * SHARED scenario: in-memory, app-scoped, votable backend (seed / failNext).
611
- * See {@link MockSharedScenario}. When omitted, the shared store starts EMPTY
612
- * — the `SHARED_*` protocol is answered either way (the mock host always
613
- * serves shared storage now).
660
+ * See {@link MockSharedScenario}. When omitted, the shared store starts EMPTY.
661
+ *
662
+ * ⚠️ As with {@link MockHostOptions.storage}, this governs the BACKEND and not
663
+ * authorization: `SHARED_*` is additionally gated on
664
+ * {@link MockHostOptions.declaredScopes} (`apps:storage:shared:read` /
665
+ * `:write`), which defaults to empty.
614
666
  */
615
667
  shared?: MockSharedScenario;
616
668
  /** Host theme delivered in `BLOCK_INIT` + context. Default `'dark'`. */
@@ -39,6 +39,7 @@
39
39
  */
40
40
  import { APP_STORAGE_ERROR_REQUEST_FAILED, APP_STORAGE_ERROR_USER_QUOTA_EXCEEDED, APP_STORAGE_ERROR_USER_ROW_LIMIT, APP_STORAGE_ERROR_VALUE_TOO_LARGE, APP_STORAGE_MAX_BYTES, APP_STORAGE_MAX_ROWS, APP_STORAGE_MAX_VALUE_BYTES, BrowsingLevel, SFW_LEVELS, } from '@civitai/app-sdk/blocks';
41
41
  import { consentUnavailablePayload, isKnownBlockScope, resolveUngrantableConsentNotice, } from './consent.js';
42
+ import { requiredStorageScope, storageResultType, storageScopeDeniedMessage, storageScopeDeniedPayload, } from './mockHostScopes.js';
42
43
  import { hostContextWithTheme } from '../transport/transport.js';
43
44
  import { isRoutableRequestId } from '../transport/requestId.js';
44
45
  /**
@@ -849,6 +850,19 @@ export function createMockHost(options = {}) {
849
850
  * precisely what `./consent.js`'s header warns about.
850
851
  */
851
852
  const extraGrantedScopes = new Set();
853
+ /**
854
+ * What the app's manifest DECLARES — the set the real host's token mint draws
855
+ * from, and the set the storage gate tests presence in.
856
+ *
857
+ * 🔴 Deliberately NOT defaulted to anything permissive. See
858
+ * {@link MockHostOptions.declaredScopes}: a permissive default would leave the
859
+ * pre-gate behaviour in place for every app that did not opt in, which is the
860
+ * population the gate exists for.
861
+ *
862
+ * This is a SNAPSHOT at install time, unlike `consentGranted`, which
863
+ * `setScenario` can toggle. A manifest does not change mid-session.
864
+ */
865
+ const declaredScopeSet = new Set(options.declaredScopes ?? []);
852
866
  /** Everything the CURRENT token carries. One reader, so the two cannot drift. */
853
867
  const currentScopes = () => [
854
868
  ...(consentGranted ? [BUDGETED_SCOPE] : []),
@@ -911,6 +925,30 @@ export function createMockHost(options = {}) {
911
925
  const typed = msg;
912
926
  options.onOutbound?.({ type: typed.type, payload: typed.payload });
913
927
  const requestId = typed.payload?.requestId;
928
+ // ---- THE STORAGE SCOPE GATE — see ./mockHostScopes.ts ----
929
+ //
930
+ // The server's test is PRESENCE in the block's approved scope set, so an
931
+ // UNDECLARED scope is refused before the backend is ever consulted. That
932
+ // ordering is the point: it refuses even when the scenario, the quota and
933
+ // the row budget would all have allowed the op, because production does.
934
+ //
935
+ // 🔴 IT SITS AHEAD OF THE SWITCH SO ONE RULE COVERS EVERY SURFACE. The
936
+ // alternative — a check inside each of the 15 storage handlers — is the
937
+ // shape that regenerates the same omission at every new site: a storage
938
+ // message added later is governed the moment it joins the table in
939
+ // `mockHostScopes.ts`, rather than whenever someone remembers to copy a
940
+ // guard into its handler.
941
+ {
942
+ const needed = requiredStorageScope(typed.type);
943
+ if (needed !== null && !declaredScopeSet.has(needed)) {
944
+ const error = storageScopeDeniedMessage(typed.type, needed);
945
+ dispatchToBlock({
946
+ type: storageResultType(typed.type),
947
+ payload: storageScopeDeniedPayload(typed.type, requestId, error),
948
+ });
949
+ return;
950
+ }
951
+ }
914
952
  switch (typed.type) {
915
953
  case 'REQUEST_TOKEN':
916
954
  dispatchToBlock({
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/blocks-react",
3
- "version": "0.61.1",
3
+ "version": "0.62.0",
4
4
  "description": "React hooks and iframe transport for Civitai Apps. Pairs with @civitai/app-sdk/blocks.",
5
5
  "license": "MIT",
6
6
  "type": "module",