@civitai/app-sdk 0.51.0 → 0.51.2

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/README.md CHANGED
@@ -134,7 +134,7 @@ interface BlockInitPayload {
134
134
 
135
135
  | Export | What |
136
136
  |---|---|
137
- | `BLOCK_SCOPES` / `BLOCK_SCOPE_PATTERN` | The 15 known block scope strings (the authoritative enum the canonical schema validates `scopes` against) + the `domain:verb:target` format-helper regex. A scope is valid only if it's a member of `BLOCK_SCOPES`, matching the [canonical schema](https://civitai.com/schemas/app-block/v1.json). |
137
+ | `BLOCK_SCOPES` / `BLOCK_SCOPE_PATTERN` | Every known block scope string (the authoritative enum the canonical schema validates `scopes` against) + the `domain:verb:target` format-helper regex. Read the current set from `BLOCK_SCOPES` itself — scopes are added and retired, so no count is quoted here. A scope is valid only if it's a member of `BLOCK_SCOPES`, matching the [canonical schema](https://civitai.com/schemas/app-block/v1.json). |
138
138
  | `isMessage(data, type)` | Discriminator-only message narrowing (see above). |
139
139
  | `isModelSlotContext(ctx)` / `isPageSlotContext(ctx)` | Runtime narrowing for the `slotId`-discriminated `BlockContext` union. Real checks on a value that crossed a `postMessage` boundary — they verify every field they assert, not just `slotId`. |
140
140
  | `isSignedIn(viewer)` | **The sign-in gate.** `isSignedIn(useBlockContext().viewer)` — do not open-code it as `viewer !== null` or `viewer?.signedIn === true`. Which of those is correct has already changed once with the host contract, and this is the one place it is decided. It reads neither `viewer.id` nor `viewer.username` (both `@deprecated`), so nothing written through it changes when those are removed. Need the identity rather than the presence? `useViewer()` — scope-gated and audited per call. |
@@ -150,10 +150,14 @@ own key:
150
150
  omits `mode` entirely and still lands here, unchanged.
151
151
  - **`WorkflowBodyCustomComfyInline`** (`mode: 'inline'`) — the block ships the ComfyUI graph
152
152
  itself as `workflow`, with a declared `resources` AIR manifest and a `maxBuzz` ceiling
153
- that is **also the step timeout in seconds**. Server-side this arm is **app-developer-only
154
- and page-token-only**, and code review is replaced by three fail-closed gates (AIR
155
- containment, entitlement, and a moderation sweep over every string leaf in the graph). A
156
- registered recipe remains the way to reach every viewer.
153
+ that is **also the step timeout in seconds**. Server-side this arm is **page-token-only**,
154
+ and code review is replaced by three fail-closed gates (AIR containment, entitlement, and a
155
+ moderation sweep over every string leaf in the graph). 🔴 It is **not** app-developer-only —
156
+ this line said it was, and nothing on either `customComfy` arm checks app-developer status,
157
+ so an ordinary viewer of your published block can reach it. `WorkflowBodyCustomComfyInline`'s
158
+ doc comment enumerates the refusals that DO run. A registered recipe is how you get a
159
+ reviewed graph you do not have to ship in the body, not a way onto a surface inline cannot
160
+ reach.
157
161
 
158
162
  `WorkflowBody`'s `step` member is likewise a union of two arms, mirroring the host's
159
163
  `blockStepMemberSchema`. Here the discriminator is the **presence of `step`**, so narrow with
@@ -3,9 +3,12 @@
3
3
  *
4
4
  * The host (civitai/civitai) projects an authoritative `maxBrowsingLevel`
5
5
  * BITMASK into `BLOCK_INIT` — the max NSFW levels the surrounding color-domain
6
- * allows (computed server-side from `domainBrowsingCeiling(color)`). A block
7
- * reads it via `useDomainMaturity()` to decide whether to surface mature
8
- * affordances.
6
+ * allows (computed server-side from `domainBrowsingCeiling(color)`) — and,
7
+ * alongside it, the viewer-narrowed `effectiveBrowsingLevel`. A block decides
8
+ * whether to surface mature affordances from the PAIR, via
9
+ * {@link effectiveBrowsingCeiling}; `@civitai/blocks-react`'s
10
+ * `useDomainMaturity()` is the React wrapper over exactly that. The domain
11
+ * ceiling alone is not the answer — see {@link ColorDomain}.
9
12
  *
10
13
  * The per-level bit VALUES below mirror civitai/civitai's server `NsfwLevel`
11
14
  * enum. They are STABLE wire values (a level's bit never changes), so it is
@@ -44,7 +47,7 @@ export declare const SFW_LEVELS: number;
44
47
  */
45
48
  export declare const NSFW_LEVELS: number;
46
49
  /**
47
- * True when the domain's browsing-level ceiling permits NO NSFW content.
50
+ * True when the given browsing-level ceiling permits NO NSFW content.
48
51
  *
49
52
  * Derived purely from the BITMASK: SFW ⇔ the ceiling has no NSFW bits set.
50
53
  *
@@ -54,7 +57,17 @@ export declare const NSFW_LEVELS: number;
54
57
  * block must therefore treat "unknown" as SFW and hide mature affordances
55
58
  * until proven otherwise.
56
59
  *
57
- * @param maxBrowsingLevel the domain ceiling bitmask from `BLOCK_INIT`.
60
+ * ⚠ A NEGATIVE ceiling is none of those three and fails OPEN:
61
+ * `isSfwCeiling(-1)` is `false` and `isLevelAllowed(XXX, -1)` is `true`, because
62
+ * two's complement sets every bit. {@link effectiveBrowsingCeiling} guards a
63
+ * negative VIEWER level but not a negative DOMAIN ceiling, so the recipe does
64
+ * not neutralise it either. Stated rather than fixed: adding the guard is a
65
+ * behaviour change and belongs in its own release.
66
+ *
67
+ * @param maxBrowsingLevel the ceiling to test — WHICHEVER you pass. Named for
68
+ * the domain mask because that was the only ceiling when this shipped; when
69
+ * gating for a viewer pass {@link effectiveBrowsingCeiling}'s result instead,
70
+ * or this answers "is the DOMAIN SFW?" and not "may I show THIS viewer".
58
71
  * @example
59
72
  * isSfwCeiling(BrowsingLevel.PG | BrowsingLevel.PG13); // true (green/blue)
60
73
  * isSfwCeiling(BrowsingLevel.PG | BrowsingLevel.X); // false (mature)
@@ -62,14 +75,24 @@ export declare const NSFW_LEVELS: number;
62
75
  */
63
76
  export declare function isSfwCeiling(maxBrowsingLevel?: number | null): boolean;
64
77
  /**
65
- * True when a specific browsing `level` bit is permitted by the domain ceiling.
78
+ * True when a specific browsing `level` bit is permitted by the given ceiling.
66
79
  *
67
80
  * **Fail-closed.** A missing / null / non-finite ceiling permits ONLY SFW
68
81
  * levels (PG / PG13) — same fail-closed posture as {@link isSfwCeiling}. A
69
82
  * non-finite / non-positive `level` returns `false`.
70
83
  *
84
+ * ⚠ A NEGATIVE ceiling is none of those three and fails OPEN:
85
+ * `isSfwCeiling(-1)` is `false` and `isLevelAllowed(XXX, -1)` is `true`, because
86
+ * two's complement sets every bit. {@link effectiveBrowsingCeiling} guards a
87
+ * negative VIEWER level but not a negative DOMAIN ceiling, so the recipe does
88
+ * not neutralise it either. Stated rather than fixed: adding the guard is a
89
+ * behaviour change and belongs in its own release.
90
+ *
71
91
  * @param level a single `BrowsingLevel` bit (e.g. `BrowsingLevel.R`).
72
- * @param maxBrowsingLevel the domain ceiling bitmask from `BLOCK_INIT`.
92
+ * @param maxBrowsingLevel the ceiling to test — WHICHEVER you pass. Named for
93
+ * the domain mask because that was the only ceiling when this shipped; when
94
+ * gating for a viewer pass {@link effectiveBrowsingCeiling}'s result instead,
95
+ * or this answers what the DOMAIN permits and not what THIS viewer may see.
73
96
  * @example
74
97
  * isLevelAllowed(BrowsingLevel.R, BrowsingLevel.PG | BrowsingLevel.PG13); // false
75
98
  * isLevelAllowed(BrowsingLevel.PG13, undefined); // true
@@ -126,10 +149,28 @@ export declare function isLevelAllowed(level: number, maxBrowsingLevel?: number
126
149
  */
127
150
  export declare function effectiveBrowsingCeiling(maxBrowsingLevel?: number | null, effectiveBrowsingLevel?: number | null): number;
128
151
  /**
129
- * The color-domain a block is rendered inside, as projected by the host. The
130
- * SFW policy is NOT derived from this — use {@link isSfwCeiling} on the
131
- * accompanying `maxBrowsingLevel` mask instead. `null` / absent means the host
132
- * did not project a domain (treat as unknown ⇒ fail-closed SFW).
152
+ * The color-domain a block is rendered inside, as projected by the host.
153
+ * Informational ONLY — the SFW policy is server-side, and this string is not
154
+ * it. `null` / absent means the host did not project a domain (treat as
155
+ * unknown ⇒ fail-closed SFW).
156
+ *
157
+ * 🔴 Never gate on this string, and never on `maxBrowsingLevel` alone. Resolve
158
+ * the ceiling with {@link effectiveBrowsingCeiling}, then test it with
159
+ * {@link isSfwCeiling} or {@link isLevelAllowed}:
160
+ *
161
+ * ```ts
162
+ * const eff = effectiveBrowsingCeiling(maxBrowsingLevel, effectiveBrowsingLevel);
163
+ * // 🔴 OPPOSITE POLARITIES — do not copy these two lines as a uniform pair.
164
+ * if (isSfwCeiling(eff)) hideMatureAffordances(); // true = SFW ⇒ HIDE
165
+ * if (isLevelAllowed(BrowsingLevel.R, eff)) showR(); // true = allowed ⇒ SHOW
166
+ * ```
167
+ *
168
+ * This docblock used to say *"use `isSfwCeiling` on the accompanying
169
+ * `maxBrowsingLevel` mask instead"*. The predicate was never the problem — both
170
+ * predicates take WHATEVER CEILING YOU PASS — the problem was passing the
171
+ * domain one, which is identical for every viewer on `civitai.red` including
172
+ * one whose own NSFW setting is off, so it cannot answer "may I show THIS
173
+ * viewer mature content".
133
174
  */
134
175
  export type ColorDomain = 'green' | 'blue' | 'red';
135
176
  //# sourceMappingURL=browsingLevel.d.ts.map
@@ -3,9 +3,12 @@
3
3
  *
4
4
  * The host (civitai/civitai) projects an authoritative `maxBrowsingLevel`
5
5
  * BITMASK into `BLOCK_INIT` — the max NSFW levels the surrounding color-domain
6
- * allows (computed server-side from `domainBrowsingCeiling(color)`). A block
7
- * reads it via `useDomainMaturity()` to decide whether to surface mature
8
- * affordances.
6
+ * allows (computed server-side from `domainBrowsingCeiling(color)`) — and,
7
+ * alongside it, the viewer-narrowed `effectiveBrowsingLevel`. A block decides
8
+ * whether to surface mature affordances from the PAIR, via
9
+ * {@link effectiveBrowsingCeiling}; `@civitai/blocks-react`'s
10
+ * `useDomainMaturity()` is the React wrapper over exactly that. The domain
11
+ * ceiling alone is not the answer — see {@link ColorDomain}.
9
12
  *
10
13
  * The per-level bit VALUES below mirror civitai/civitai's server `NsfwLevel`
11
14
  * enum. They are STABLE wire values (a level's bit never changes), so it is
@@ -42,7 +45,7 @@ export const SFW_LEVELS = BrowsingLevel.PG | BrowsingLevel.PG13;
42
45
  */
43
46
  export const NSFW_LEVELS = BrowsingLevel.R | BrowsingLevel.X | BrowsingLevel.XXX;
44
47
  /**
45
- * True when the domain's browsing-level ceiling permits NO NSFW content.
48
+ * True when the given browsing-level ceiling permits NO NSFW content.
46
49
  *
47
50
  * Derived purely from the BITMASK: SFW ⇔ the ceiling has no NSFW bits set.
48
51
  *
@@ -52,7 +55,17 @@ export const NSFW_LEVELS = BrowsingLevel.R | BrowsingLevel.X | BrowsingLevel.XXX
52
55
  * block must therefore treat "unknown" as SFW and hide mature affordances
53
56
  * until proven otherwise.
54
57
  *
55
- * @param maxBrowsingLevel the domain ceiling bitmask from `BLOCK_INIT`.
58
+ * ⚠ A NEGATIVE ceiling is none of those three and fails OPEN:
59
+ * `isSfwCeiling(-1)` is `false` and `isLevelAllowed(XXX, -1)` is `true`, because
60
+ * two's complement sets every bit. {@link effectiveBrowsingCeiling} guards a
61
+ * negative VIEWER level but not a negative DOMAIN ceiling, so the recipe does
62
+ * not neutralise it either. Stated rather than fixed: adding the guard is a
63
+ * behaviour change and belongs in its own release.
64
+ *
65
+ * @param maxBrowsingLevel the ceiling to test — WHICHEVER you pass. Named for
66
+ * the domain mask because that was the only ceiling when this shipped; when
67
+ * gating for a viewer pass {@link effectiveBrowsingCeiling}'s result instead,
68
+ * or this answers "is the DOMAIN SFW?" and not "may I show THIS viewer".
56
69
  * @example
57
70
  * isSfwCeiling(BrowsingLevel.PG | BrowsingLevel.PG13); // true (green/blue)
58
71
  * isSfwCeiling(BrowsingLevel.PG | BrowsingLevel.X); // false (mature)
@@ -65,14 +78,24 @@ export function isSfwCeiling(maxBrowsingLevel) {
65
78
  return (maxBrowsingLevel & NSFW_LEVELS) === 0;
66
79
  }
67
80
  /**
68
- * True when a specific browsing `level` bit is permitted by the domain ceiling.
81
+ * True when a specific browsing `level` bit is permitted by the given ceiling.
69
82
  *
70
83
  * **Fail-closed.** A missing / null / non-finite ceiling permits ONLY SFW
71
84
  * levels (PG / PG13) — same fail-closed posture as {@link isSfwCeiling}. A
72
85
  * non-finite / non-positive `level` returns `false`.
73
86
  *
87
+ * ⚠ A NEGATIVE ceiling is none of those three and fails OPEN:
88
+ * `isSfwCeiling(-1)` is `false` and `isLevelAllowed(XXX, -1)` is `true`, because
89
+ * two's complement sets every bit. {@link effectiveBrowsingCeiling} guards a
90
+ * negative VIEWER level but not a negative DOMAIN ceiling, so the recipe does
91
+ * not neutralise it either. Stated rather than fixed: adding the guard is a
92
+ * behaviour change and belongs in its own release.
93
+ *
74
94
  * @param level a single `BrowsingLevel` bit (e.g. `BrowsingLevel.R`).
75
- * @param maxBrowsingLevel the domain ceiling bitmask from `BLOCK_INIT`.
95
+ * @param maxBrowsingLevel the ceiling to test — WHICHEVER you pass. Named for
96
+ * the domain mask because that was the only ceiling when this shipped; when
97
+ * gating for a viewer pass {@link effectiveBrowsingCeiling}'s result instead,
98
+ * or this answers what the DOMAIN permits and not what THIS viewer may see.
76
99
  * @example
77
100
  * isLevelAllowed(BrowsingLevel.R, BrowsingLevel.PG | BrowsingLevel.PG13); // false
78
101
  * isLevelAllowed(BrowsingLevel.PG13, undefined); // true
@@ -150,8 +150,21 @@ export interface BlockInitPayload {
150
150
  /**
151
151
  * The color-domain the block is rendered inside (`green` | `blue` | `red`),
152
152
  * or `null` when the host did not resolve one. Informational ONLY — the SFW
153
- * policy is server-side; derive "is this SFW?" from {@link maxBrowsingLevel}
154
- * (via `isSfwCeiling` / `useDomainMaturity`), never from this string.
153
+ * policy is server-side; never gate on this string. Resolve the ceiling
154
+ * first, then test it — in any runtime:
155
+ *
156
+ * ```ts
157
+ * const eff = effectiveBrowsingCeiling(maxBrowsingLevel, effectiveBrowsingLevel);
158
+ * // 🔴 OPPOSITE POLARITIES — do not copy these two lines as a uniform pair.
159
+ * if (isSfwCeiling(eff)) hideMatureAffordances(); // true = SFW ⇒ HIDE
160
+ * if (isLevelAllowed(BrowsingLevel.R, eff)) showR(); // true = allowed ⇒ SHOW
161
+ * ```
162
+ *
163
+ * 🔴 `effectiveBrowsingCeiling` returns a BITMASK, not a boolean — an SFW
164
+ * ceiling is `3`, which is truthy, so branching on it directly shows mature
165
+ * content to a viewer who may not see it. It needs one of the two predicates
166
+ * above. In React, `@civitai/blocks-react`'s `useDomainMaturity()` returns
167
+ * `isSfw` / `isLevelAllowed` already composed this way.
155
168
  *
156
169
  * Sent by civitai/civitai PR #2670. A host that predates it omits this field
157
170
  * (reads `undefined`).
@@ -161,8 +174,9 @@ export interface BlockInitPayload {
161
174
  * Authoritative browsing-level BITMASK = the max NSFW levels the domain
162
175
  * allows, computed server-side from `domainBrowsingCeiling(color)` (green/
163
176
  * blue → SFW, red → all). Bits mirror the server `NsfwLevel` (see
164
- * `browsingLevel.ts`). A block reads this to decide whether to surface mature
165
- * affordances — `isSfwCeiling(maxBrowsingLevel)` is the canonical test.
177
+ * `browsingLevel.ts`). `isSfwCeiling(maxBrowsingLevel)` answers "is this
178
+ * DOMAIN SFW?" — not "may I show THIS viewer mature content"; for that, see
179
+ * the warning below and {@link effectiveBrowsingLevel}.
166
180
  *
167
181
  * Sent by civitai/civitai PR #2670. A host that predates it omits this field
168
182
  * (reads `undefined`); the SDK fail-closes to SFW when it is absent.
@@ -193,9 +207,13 @@ export interface BlockInitPayload {
193
207
  * WIDER than the domain permits and is not permission for anything.
194
208
  *
195
209
  * ADDITIVE + OPTIONAL. A host that predates it omits the field, in which case
196
- * `useDomainMaturity()` falls back to `maxBrowsingLevel` — i.e. exactly the
197
- * behaviour that host already had. Read it through the hook rather than
198
- * directly, so the fallback and the fail-closed defaults are applied for you.
210
+ * the ceiling falls back to {@link maxBrowsingLevel} — i.e. exactly the
211
+ * behaviour that host already had. Do not read this field directly: pass it
212
+ * through `effectiveBrowsingCeiling`, which applies that fallback and the
213
+ * fail-closed defaults for you. `@civitai/blocks-react`'s
214
+ * `useDomainMaturity()` is the React wrapper over the same thing — it is one
215
+ * way to get this right, not the only one, since this module is
216
+ * runtime-agnostic and two of the starters ship without React.
199
217
  */
200
218
  effectiveBrowsingLevel?: number;
201
219
  }
@@ -33,7 +33,7 @@ export type BlockScope = (typeof BLOCK_SCOPES)[BlockScopeKey];
33
33
  *
34
34
  * NOTE: this regex is **not** the authoritative validity contract. The
35
35
  * canonical manifest schema (https://civitai.com/schemas/app-block/v1.json)
36
- * validates `scopes` by MEMBERSHIP in a fixed enum — i.e. the 12 values in
36
+ * validates `scopes` by MEMBERSHIP in a fixed enum — i.e. exactly the values in
37
37
  * {@link BLOCK_SCOPES}. `defineBlock` therefore gates on membership in
38
38
  * `BLOCK_SCOPES`; this pattern is kept only as a FORMAT HEURISTIC to give a
39
39
  * pointed error message (e.g. distinguishing a malformed/PascalCase scope from
@@ -55,7 +55,7 @@ export const BLOCK_SCOPES = {
55
55
  *
56
56
  * NOTE: this regex is **not** the authoritative validity contract. The
57
57
  * canonical manifest schema (https://civitai.com/schemas/app-block/v1.json)
58
- * validates `scopes` by MEMBERSHIP in a fixed enum — i.e. the 12 values in
58
+ * validates `scopes` by MEMBERSHIP in a fixed enum — i.e. exactly the values in
59
59
  * {@link BLOCK_SCOPES}. `defineBlock` therefore gates on membership in
60
60
  * `BLOCK_SCOPES`; this pattern is kept only as a FORMAT HEURISTIC to give a
61
61
  * pointed error message (e.g. distinguishing a malformed/PascalCase scope from
@@ -856,13 +856,30 @@ export type InlineComfyNode = {
856
856
  * graph". That is FALSE and was removed: it predates the inline arm and cost a
857
857
  * developer a dogfooding session, who trusted it over a working feature.
858
858
  *
859
- * WHO CAN USE IT. Narrower than the recipe arm, and both gates are server-side:
860
- * - **App developers only** — the host runs `assertViewerIsAppDeveloper` on
861
- * every `customComfy` estimate AND submit. A non-developer viewer of your
862
- * published block cannot submit one.
859
+ * 🔴 IT IS NOT "APP DEVELOPERS ONLY", AND THAT CLAIM USED TO BE HERE. This
860
+ * block read "**App developers only** — the host runs
861
+ * `assertViewerIsAppDeveloper` on every `customComfy` estimate AND submit. A
862
+ * non-developer viewer of your published block cannot submit one." Both
863
+ * sentences are FALSE: NEITHER `customComfy` arm runs any app-developer check,
864
+ * on the estimate or on the submit. The host's own schema module records the
865
+ * same retraction. Do not reintroduce it, in any wording — it is a SECURITY
866
+ * claim, and believing it is what makes an author ship an inline graph they
867
+ * would not ship to every viewer. An ordinary viewer of your published block
868
+ * CAN reach this arm once the refusals below pass.
869
+ *
870
+ * WHO CAN USE IT — stated as the refusals the host actually runs, before either
871
+ * arm's body is inspected. Each is a path your block has to handle:
863
872
  * - **Page tokens only** — a model-bound token is rejected.
864
- * So treat inline as a build/iterate primitive today. If you need a graph
865
- * available to every viewer, get it registered as a recipe.
873
+ * - The token must carry the **`ai:write:budgeted`** consent scope.
874
+ * - The viewer must be **signed in** — a token whose subject does not resolve
875
+ * is refused.
876
+ * - The viewer must be **enabled for Apps** (the runtime kill-switch, evaluated
877
+ * on the token's subject; this is a closed beta).
878
+ * - On **submit** only: the token must carry a **positive per-call Buzz
879
+ * budget**. That budget is minted from YOUR OWN manifest
880
+ * (`page.buzzBudgetPerGen`) — it is not a property of the viewer.
881
+ * A registered recipe is how you get a reviewed graph you do not have to ship in
882
+ * the body. It is not a way onto a surface the inline arm cannot reach.
866
883
  *
867
884
  * WHAT REPLACED CODE REVIEW. A recipe is reviewed in-repo, and that review was
868
885
  * the trust root. An inline graph has none, so the host substitutes three
@@ -997,8 +1014,10 @@ export type WorkflowBodyCustomComfyInline = {
997
1014
  * - {@link WorkflowBodyCustomComfyRecipe} (`mode` omitted, or `'recipe'`) —
998
1015
  * names a server-registered, code-reviewed recipe. The default.
999
1016
  * - {@link WorkflowBodyCustomComfyInline} (`mode: 'inline'`) — ships the
1000
- * ComfyUI graph itself. Developer-only, page-tokens-only, and fenced by
1001
- * three server-side gates instead of code review.
1017
+ * ComfyUI graph itself. Page-tokens-only, and fenced by three server-side
1018
+ * gates instead of code review. 🔴 NOT developer-only — this line said it was,
1019
+ * and no `customComfy` arm runs an app-developer check. See that type's own
1020
+ * doc comment for the refusals that DO run.
1002
1021
  *
1003
1022
  * Both arms are `.strict()` server-side, so a body naming BOTH `recipe` and
1004
1023
  * `workflow` is rejected by both rather than resolved to a winner. Narrow on
@@ -1193,7 +1212,8 @@ export type WorkflowBodyPassThroughStep = {
1193
1212
  * itself a union on `mode`: a bounded, server-registered
1194
1213
  * {@link WorkflowBodyCustomComfyRecipe} (the default), or a
1195
1214
  * {@link WorkflowBodyCustomComfyInline} graph the block ships itself
1196
- * (`mode: 'inline'`; developer-only).
1215
+ * (`mode: 'inline'`; page-tokens-only, and NOT developer-only — that claim
1216
+ * used to be here and is false).
1197
1217
  * - {@link WorkflowBodyStep} (`kind: 'step'`, `step` PRESENT) — a bounded,
1198
1218
  * server-registered orchestrator step (the host's step registry; billing mode
1199
1219
  * and moderation posture are declared per entry).
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * `@civitai/app-sdk/safe-storage` — survive an opaque-origin sandbox.
3
3
  *
4
+ * 🔴 `@civitai/sdk`'s `src/safe-storage/index.ts` is a deliberate independent
5
+ * COPY of this file (the successor must not depend on its predecessor). The two
6
+ * bodies are held byte-identical modulo comments by
7
+ * `tests/guards/safe-storage-copy-parity.test.mjs`, so a fix here goes red
8
+ * until it is applied there too.
9
+ *
4
10
  * Civitai Apps run in an iframe sandboxed as `allow-scripts allow-forms`,
5
11
  * deliberately WITHOUT `allow-same-origin`. The document therefore has an
6
12
  * **opaque origin**, and there is no origin to key web storage against, so the
@@ -54,8 +60,19 @@ export declare function createMemoryStorage(): Storage;
54
60
  * with an in-memory `Storage`, so third-party code that touches it unguarded
55
61
  * cannot throw.
56
62
  *
57
- * - **No-op where storage works.** A healthy `Storage` is never replaced, and
58
- * its contents are never touched.
63
+ * - **No-op where storage works — but not a no-TOUCH.** A healthy `Storage` is
64
+ * never replaced and its contents are unchanged, but it *is* written to:
65
+ * classifying it means a real round trip, so each of `localStorage` and
66
+ * `sessionStorage` gets `setItem('__civitai_app_sdk_storage_probe__', '1')`
67
+ * immediately followed by `removeItem`. MEASURED against the built artifact
68
+ * with instrumented healthy stores: exactly those four operations, the store
69
+ * objects not replaced, contents identical before and after. Per the Web
70
+ * Storage spec every one of those writes queues a `storage` event in the
71
+ * *other* documents that share the store, so at a REAL origin a cross-tab
72
+ * listener that does not filter by key sees spurious events (the two
73
+ * `localStorage` writes are the ones another tab receives). At the opaque
74
+ * origin this module exists for there is no other same-origin document, so
75
+ * there is nothing to receive them.
59
76
  * - **No-op where storage is absent** (Node/SSR/workers). Nothing is invented.
60
77
  * - **Never loses readable data.** When the old store reads but refuses writes
61
78
  * (full / disabled), its entries are copied into the fallback first, so the
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * `@civitai/app-sdk/safe-storage` — survive an opaque-origin sandbox.
3
3
  *
4
+ * 🔴 `@civitai/sdk`'s `src/safe-storage/index.ts` is a deliberate independent
5
+ * COPY of this file (the successor must not depend on its predecessor). The two
6
+ * bodies are held byte-identical modulo comments by
7
+ * `tests/guards/safe-storage-copy-parity.test.mjs`, so a fix here goes red
8
+ * until it is applied there too.
9
+ *
4
10
  * Civitai Apps run in an iframe sandboxed as `allow-scripts allow-forms`,
5
11
  * deliberately WITHOUT `allow-same-origin`. The document therefore has an
6
12
  * **opaque origin**, and there is no origin to key web storage against, so the
@@ -272,8 +278,19 @@ function seedFrom(existing, into) {
272
278
  * with an in-memory `Storage`, so third-party code that touches it unguarded
273
279
  * cannot throw.
274
280
  *
275
- * - **No-op where storage works.** A healthy `Storage` is never replaced, and
276
- * its contents are never touched.
281
+ * - **No-op where storage works — but not a no-TOUCH.** A healthy `Storage` is
282
+ * never replaced and its contents are unchanged, but it *is* written to:
283
+ * classifying it means a real round trip, so each of `localStorage` and
284
+ * `sessionStorage` gets `setItem('__civitai_app_sdk_storage_probe__', '1')`
285
+ * immediately followed by `removeItem`. MEASURED against the built artifact
286
+ * with instrumented healthy stores: exactly those four operations, the store
287
+ * objects not replaced, contents identical before and after. Per the Web
288
+ * Storage spec every one of those writes queues a `storage` event in the
289
+ * *other* documents that share the store, so at a REAL origin a cross-tab
290
+ * listener that does not filter by key sees spurious events (the two
291
+ * `localStorage` writes are the ones another tab receives). At the opaque
292
+ * origin this module exists for there is no other same-origin document, so
293
+ * there is nothing to receive them.
277
294
  * - **No-op where storage is absent** (Node/SSR/workers). Nothing is invented.
278
295
  * - **Never loses readable data.** When the old store reads but refuses writes
279
296
  * (full / disabled), its entries are copied into the fallback first, so the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/app-sdk",
3
- "version": "0.51.0",
3
+ "version": "0.51.2",
4
4
  "description": "OAuth + PKCE, encrypted-cookie sessions, scopes, and orchestrator helpers for building third-party Civitai apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -72,7 +72,7 @@
72
72
  },
73
73
  "auth": {
74
74
  "type": "string",
75
- "description": "Which credential the host hands the block: \"block-token\" (default when omitted) is the block-scoped JWT; \"oauth\" opts the block into a real OAuth access token for its own OauthClient, accepted unchanged by /api/v1, the orchestrator and the MCP.",
75
+ "description": "Which credential the host hands the block: \"block-token\" (default when omitted) is the block-scoped JWT; \"oauth\" asks for an opaque OAuth access token for the block's own OauthClient, which ordinary /api/v1 routes accept as a viewer bearer. Minting it is gated on a server flag that is not generally enabled, and also requires a signed-in viewer and a declared \"user:read:self\" scope; when any of those is unmet the host hands back a block JWT instead of failing, so branch on the token kind the host reports rather than on this field. An OAuth token is refused by the postMessage bridge procedures, which verify a block JWT only, and declaring \"oauth\" alongside any \"apps:storage:*\" scope is refused at submit time.",
76
76
  "enum": ["block-token", "oauth"]
77
77
  },
78
78
  "trustTier": {