@civitai/app-sdk 0.54.0 → 0.56.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/blocks/idempotency.d.ts +171 -0
- package/dist/blocks/idempotency.js +209 -0
- package/dist/blocks/index.d.ts +1 -0
- package/dist/blocks/index.js +1 -0
- package/dist/blocks/types.d.ts +16 -0
- package/package.json +1 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The IDEMPOTENCY-KEY FORMAT the host enforces on every money POST — **the only
|
|
3
|
+
* site in this repository that spells this rule**.
|
|
4
|
+
*
|
|
5
|
+
* Sibling of `./appStorageLimits.ts`, for the same reason: the rule lives in the
|
|
6
|
+
* host, the SDK cannot change it, and a hand-copied regex at each call site is
|
|
7
|
+
* how the copies come to agree with each other and disagree with the host.
|
|
8
|
+
*
|
|
9
|
+
* ## THE MOTIVATING FAILURE (2026-10-02)
|
|
10
|
+
*
|
|
11
|
+
* A character-sheet block composed its key as `sheetId:panelId:nonce` — a
|
|
12
|
+
* perfectly reasonable-looking composite id. It passed **201 local tests**, the
|
|
13
|
+
* dev harness and review. Every save in production failed:
|
|
14
|
+
*
|
|
15
|
+
* ```json
|
|
16
|
+
* { "code": "invalid_format", "format": "regex",
|
|
17
|
+
* "pattern": "/^[A-Za-z0-9_-]{1,64}$/",
|
|
18
|
+
* "path": ["idempotencyKey"],
|
|
19
|
+
* "message": "Invalid string: must match pattern /^[A-Za-z0-9_-]{1,64}$/" }
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* `BAD_REQUEST` / httpStatus 400 on path `blocks.submitWorkflow`.
|
|
23
|
+
*
|
|
24
|
+
* 🔴 **THE REASON IT GOT THAT FAR: NOTHING IN THIS REPOSITORY MODELLED THE
|
|
25
|
+
* RULE.** The SDK's own key GENERATOR was tested against the charset, but a
|
|
26
|
+
* `crypto.randomUUID()` and the `idem-<base36>` fallback both conform and
|
|
27
|
+
* always did — so the one guard that existed covered the half that cannot fail,
|
|
28
|
+
* while a CALLER-SUPPLIED key went through unexamined at every hook, and the dev
|
|
29
|
+
* mock host had no concept of `idempotencyKey` at all.
|
|
30
|
+
*
|
|
31
|
+
* ## PROVENANCE — measured, not assumed
|
|
32
|
+
*
|
|
33
|
+
* Read from the `civitai/civitai` working tree on **2026-10-02**, file
|
|
34
|
+
* `src/server/utils/block-gen-idempotency.ts` line 77:
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* export const BLOCK_IDEMPOTENCY_KEY_REGEX = /^[A-Za-z0-9_-]{1,64}$/;
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* Enforced at **four** host entry points, which is why no single endpoint fix
|
|
41
|
+
* would have closed it:
|
|
42
|
+
*
|
|
43
|
+
* | entry point | file:line | optional? |
|
|
44
|
+
* |---|---|---|
|
|
45
|
+
* | tRPC `blocks.submitWorkflow` (the one that 400'd) | `src/server/routers/blocks.router.ts:6227` | `.optional()` |
|
|
46
|
+
* | REST `POST /api/v1/blocks/workflows/submit` | `src/pages/api/v1/blocks/workflows/submit.ts:135` | **REQUIRED** |
|
|
47
|
+
* | REST `POST /api/v1/blocks/goods/purchase` | `src/pages/api/v1/blocks/goods/purchase.ts:79` | `.optional()` |
|
|
48
|
+
* | REST `POST /api/v1/blocks/tip` | `src/pages/api/v1/blocks/tip.ts:88` | `.optional()` |
|
|
49
|
+
*
|
|
50
|
+
* 🔴 **THE TWO SURFACES ANSWER DIFFERENT ERROR ENVELOPES — do not quote one for
|
|
51
|
+
* the other.** The tRPC/bridge path (the one the block→host `SUBMIT_WORKFLOW`
|
|
52
|
+
* message takes, and the one that produced the report above) answers the
|
|
53
|
+
* `invalid_format` object shown earlier, which reaches a block as a host
|
|
54
|
+
* `errorSnapshot`. The three REST routes answer, verbatim and all three
|
|
55
|
+
* identically (verified 2026-10-02 at `submit.ts:160`, `tip.ts:127`,
|
|
56
|
+
* `goods/purchase.ts:117`):
|
|
57
|
+
*
|
|
58
|
+
* ```json
|
|
59
|
+
* { "error": "Invalid request body", "details": <zod flatten()> }
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* also with HTTP 400. So there is no single "the 400 payload" for this field —
|
|
63
|
+
* which path you are on decides it.
|
|
64
|
+
*
|
|
65
|
+
* Re-derive rather than trusting this comment:
|
|
66
|
+
*
|
|
67
|
+
* ```sh
|
|
68
|
+
* gh api repos/civitai/civitai/contents/src/server/utils/block-gen-idempotency.ts \
|
|
69
|
+
* --jq '.content' | base64 -d | grep -n 'BLOCK_IDEMPOTENCY_KEY_REGEX'
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* ## 🔴 WHY THE COLON BAN IS A CORRECTNESS INVARIANT, NOT COSMETIC
|
|
73
|
+
*
|
|
74
|
+
* The host's own comment calls the charset "colon-free", and two of its redis
|
|
75
|
+
* key builders depend on that being true:
|
|
76
|
+
*
|
|
77
|
+
* - `block-gen-idempotency.ts` composes `<prefix>:<userId>:<appBlockId>:<key>`
|
|
78
|
+
* and documents it as **INJECTIVE** *because* "`idempotencyKey` is
|
|
79
|
+
* charset-restricted … (colon-free). So no two distinct (user, app, key)
|
|
80
|
+
* triples can ever collide on the delimiter."
|
|
81
|
+
* - `block-tip-rate-limit.ts:215` composes the tip idempotency key the same
|
|
82
|
+
* way, with the same stated justification.
|
|
83
|
+
*
|
|
84
|
+
* A colon-bearing key therefore does not merely fail a cosmetic check — it would
|
|
85
|
+
* make those keys **non-injective**, letting one app's slot alias another's. The
|
|
86
|
+
* tip module spells out the harm in that event: app B "would then REPLAY app A's
|
|
87
|
+
* cached response body verbatim, LEARNING A's tip recipient and amount, while
|
|
88
|
+
* B's own tip silently never happens." The 400 is what prevents it.
|
|
89
|
+
*
|
|
90
|
+
* Separately, the key is substringed into the orchestrator `externalId`
|
|
91
|
+
* (`blk<NN><appBlockId><key>`), whose own contract is `^[A-Za-z0-9_-]+$` with a
|
|
92
|
+
* **128**-char ceiling — a colon is outside that charset too. The host's source
|
|
93
|
+
* records that an earlier revision assumed a colon-delimited id and that it
|
|
94
|
+
* "would have 400'd every keyed block generation submit."
|
|
95
|
+
*
|
|
96
|
+
* ## 🔴 WHERE THE 64 COMES FROM
|
|
97
|
+
*
|
|
98
|
+
* It is DERIVED, not chosen. The host composes the orchestrator `externalId` as
|
|
99
|
+
* `blk<NN><appBlockId><key>`; `ORCHESTRATOR_EXTERNAL_ID_MAX` is 128. 64 keeps the
|
|
100
|
+
* worst-case composition inside 128. Do not "relax" it here — the SDK is not the
|
|
101
|
+
* authority, and a key this module accepts but the host rejects is the exact
|
|
102
|
+
* defect above, reintroduced.
|
|
103
|
+
*
|
|
104
|
+
* ## 🔴 REFUSE, NEVER REWRITE
|
|
105
|
+
*
|
|
106
|
+
* Nothing in this module sanitises, truncates or normalises a key, and no
|
|
107
|
+
* consumer may. An idempotency key is an **identity**: silently rewriting a
|
|
108
|
+
* caller's key breaks the property the key exists to provide — two distinct
|
|
109
|
+
* logical submits could collapse onto one slot (one charge for two intended
|
|
110
|
+
* operations), or a retry could be rewritten differently from the first attempt
|
|
111
|
+
* and mint a SECOND reservation for one logical operation. Both are money bugs,
|
|
112
|
+
* and both are quieter than a rejection. A malformed key is a defect in the
|
|
113
|
+
* calling block's code; the only safe response is to refuse it loudly, before
|
|
114
|
+
* anything is sent.
|
|
115
|
+
*/
|
|
116
|
+
/**
|
|
117
|
+
* The host's charset + length rule for a money-POST idempotency key, vendored
|
|
118
|
+
* verbatim from `block-gen-idempotency.ts:77`.
|
|
119
|
+
*
|
|
120
|
+
* 🔴 NO `g` FLAG, DELIBERATELY. A `g`-flagged regex carries `lastIndex` across
|
|
121
|
+
* calls, so repeated `.test()` on the SAME instance alternates true/false for an
|
|
122
|
+
* identical input — which on this surface would mean a key accepted on one
|
|
123
|
+
* submit and refused on its retry.
|
|
124
|
+
*/
|
|
125
|
+
export declare const BLOCK_IDEMPOTENCY_KEY_REGEX: RegExp;
|
|
126
|
+
/**
|
|
127
|
+
* The host's length ceiling, named so a caller composing a key can budget
|
|
128
|
+
* against it instead of rediscovering 64 from a rejection.
|
|
129
|
+
*
|
|
130
|
+
* Kept as its own constant rather than parsed back out of the regex: this is the
|
|
131
|
+
* number a caller does arithmetic with, and the two are pinned to each other by
|
|
132
|
+
* `blockIdempotencyKeyRejection`'s own tests.
|
|
133
|
+
*/
|
|
134
|
+
export declare const BLOCK_IDEMPOTENCY_KEY_MAX_LENGTH = 64;
|
|
135
|
+
/**
|
|
136
|
+
* Does `value` clear the host's rule?
|
|
137
|
+
*
|
|
138
|
+
* Returns `false` for a non-string (including `null`/`undefined`) rather than
|
|
139
|
+
* throwing: callers reach this with untrusted input from a block's own code, and
|
|
140
|
+
* a type-level `string` is not a runtime guarantee at a package boundary.
|
|
141
|
+
*
|
|
142
|
+
* 🔴 This is the predicate EVERY consumer must call. Re-spelling the regex at a
|
|
143
|
+
* call site is what produced the defect in this module's header.
|
|
144
|
+
*
|
|
145
|
+
* 🔴 RETURNS `boolean`, NOT A TYPE PREDICATE (`value is string`), DELIBERATELY.
|
|
146
|
+
* A predicate signature narrows the FALSE branch to `Exclude<string, string>` =
|
|
147
|
+
* `never` for an already-`string` argument, which silently makes the rejection
|
|
148
|
+
* path below uncompilable — and in other callers would make a legitimate
|
|
149
|
+
* else-branch look like dead code. The narrowing bought nothing: no caller needs
|
|
150
|
+
* `unknown` → `string` narrowing from this function.
|
|
151
|
+
*/
|
|
152
|
+
export declare function isValidBlockIdempotencyKey(value: unknown): boolean;
|
|
153
|
+
/**
|
|
154
|
+
* A developer-facing explanation of WHY `value` was refused, or `null` when it
|
|
155
|
+
* is valid.
|
|
156
|
+
*
|
|
157
|
+
* 🔴 DEVELOPER-FACING, NEVER VIEWER-FACING. A malformed idempotency key is a bug
|
|
158
|
+
* in the block's own code — no retry fixes it and no viewer can act on it, so
|
|
159
|
+
* rendering this string into UI tells the wrong person. It names the offending
|
|
160
|
+
* value because that is the single most useful fact for the person who has to
|
|
161
|
+
* fix it, and because the key is the block's own construction, not viewer data.
|
|
162
|
+
*
|
|
163
|
+
* The returned text is not a contract — branch on
|
|
164
|
+
* {@link isValidBlockIdempotencyKey}, never on this wording.
|
|
165
|
+
*
|
|
166
|
+
* Reasons are reported SPECIFICALLY rather than as one generic sentence: "it has
|
|
167
|
+
* a colon" and "it is 71 characters" lead to different fixes, and the colon case
|
|
168
|
+
* is the one that looks most like valid input.
|
|
169
|
+
*/
|
|
170
|
+
export declare function blockIdempotencyKeyRejection(value: unknown): string | null;
|
|
171
|
+
//# sourceMappingURL=idempotency.d.ts.map
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The IDEMPOTENCY-KEY FORMAT the host enforces on every money POST — **the only
|
|
3
|
+
* site in this repository that spells this rule**.
|
|
4
|
+
*
|
|
5
|
+
* Sibling of `./appStorageLimits.ts`, for the same reason: the rule lives in the
|
|
6
|
+
* host, the SDK cannot change it, and a hand-copied regex at each call site is
|
|
7
|
+
* how the copies come to agree with each other and disagree with the host.
|
|
8
|
+
*
|
|
9
|
+
* ## THE MOTIVATING FAILURE (2026-10-02)
|
|
10
|
+
*
|
|
11
|
+
* A character-sheet block composed its key as `sheetId:panelId:nonce` — a
|
|
12
|
+
* perfectly reasonable-looking composite id. It passed **201 local tests**, the
|
|
13
|
+
* dev harness and review. Every save in production failed:
|
|
14
|
+
*
|
|
15
|
+
* ```json
|
|
16
|
+
* { "code": "invalid_format", "format": "regex",
|
|
17
|
+
* "pattern": "/^[A-Za-z0-9_-]{1,64}$/",
|
|
18
|
+
* "path": ["idempotencyKey"],
|
|
19
|
+
* "message": "Invalid string: must match pattern /^[A-Za-z0-9_-]{1,64}$/" }
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* `BAD_REQUEST` / httpStatus 400 on path `blocks.submitWorkflow`.
|
|
23
|
+
*
|
|
24
|
+
* 🔴 **THE REASON IT GOT THAT FAR: NOTHING IN THIS REPOSITORY MODELLED THE
|
|
25
|
+
* RULE.** The SDK's own key GENERATOR was tested against the charset, but a
|
|
26
|
+
* `crypto.randomUUID()` and the `idem-<base36>` fallback both conform and
|
|
27
|
+
* always did — so the one guard that existed covered the half that cannot fail,
|
|
28
|
+
* while a CALLER-SUPPLIED key went through unexamined at every hook, and the dev
|
|
29
|
+
* mock host had no concept of `idempotencyKey` at all.
|
|
30
|
+
*
|
|
31
|
+
* ## PROVENANCE — measured, not assumed
|
|
32
|
+
*
|
|
33
|
+
* Read from the `civitai/civitai` working tree on **2026-10-02**, file
|
|
34
|
+
* `src/server/utils/block-gen-idempotency.ts` line 77:
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* export const BLOCK_IDEMPOTENCY_KEY_REGEX = /^[A-Za-z0-9_-]{1,64}$/;
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* Enforced at **four** host entry points, which is why no single endpoint fix
|
|
41
|
+
* would have closed it:
|
|
42
|
+
*
|
|
43
|
+
* | entry point | file:line | optional? |
|
|
44
|
+
* |---|---|---|
|
|
45
|
+
* | tRPC `blocks.submitWorkflow` (the one that 400'd) | `src/server/routers/blocks.router.ts:6227` | `.optional()` |
|
|
46
|
+
* | REST `POST /api/v1/blocks/workflows/submit` | `src/pages/api/v1/blocks/workflows/submit.ts:135` | **REQUIRED** |
|
|
47
|
+
* | REST `POST /api/v1/blocks/goods/purchase` | `src/pages/api/v1/blocks/goods/purchase.ts:79` | `.optional()` |
|
|
48
|
+
* | REST `POST /api/v1/blocks/tip` | `src/pages/api/v1/blocks/tip.ts:88` | `.optional()` |
|
|
49
|
+
*
|
|
50
|
+
* 🔴 **THE TWO SURFACES ANSWER DIFFERENT ERROR ENVELOPES — do not quote one for
|
|
51
|
+
* the other.** The tRPC/bridge path (the one the block→host `SUBMIT_WORKFLOW`
|
|
52
|
+
* message takes, and the one that produced the report above) answers the
|
|
53
|
+
* `invalid_format` object shown earlier, which reaches a block as a host
|
|
54
|
+
* `errorSnapshot`. The three REST routes answer, verbatim and all three
|
|
55
|
+
* identically (verified 2026-10-02 at `submit.ts:160`, `tip.ts:127`,
|
|
56
|
+
* `goods/purchase.ts:117`):
|
|
57
|
+
*
|
|
58
|
+
* ```json
|
|
59
|
+
* { "error": "Invalid request body", "details": <zod flatten()> }
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* also with HTTP 400. So there is no single "the 400 payload" for this field —
|
|
63
|
+
* which path you are on decides it.
|
|
64
|
+
*
|
|
65
|
+
* Re-derive rather than trusting this comment:
|
|
66
|
+
*
|
|
67
|
+
* ```sh
|
|
68
|
+
* gh api repos/civitai/civitai/contents/src/server/utils/block-gen-idempotency.ts \
|
|
69
|
+
* --jq '.content' | base64 -d | grep -n 'BLOCK_IDEMPOTENCY_KEY_REGEX'
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* ## 🔴 WHY THE COLON BAN IS A CORRECTNESS INVARIANT, NOT COSMETIC
|
|
73
|
+
*
|
|
74
|
+
* The host's own comment calls the charset "colon-free", and two of its redis
|
|
75
|
+
* key builders depend on that being true:
|
|
76
|
+
*
|
|
77
|
+
* - `block-gen-idempotency.ts` composes `<prefix>:<userId>:<appBlockId>:<key>`
|
|
78
|
+
* and documents it as **INJECTIVE** *because* "`idempotencyKey` is
|
|
79
|
+
* charset-restricted … (colon-free). So no two distinct (user, app, key)
|
|
80
|
+
* triples can ever collide on the delimiter."
|
|
81
|
+
* - `block-tip-rate-limit.ts:215` composes the tip idempotency key the same
|
|
82
|
+
* way, with the same stated justification.
|
|
83
|
+
*
|
|
84
|
+
* A colon-bearing key therefore does not merely fail a cosmetic check — it would
|
|
85
|
+
* make those keys **non-injective**, letting one app's slot alias another's. The
|
|
86
|
+
* tip module spells out the harm in that event: app B "would then REPLAY app A's
|
|
87
|
+
* cached response body verbatim, LEARNING A's tip recipient and amount, while
|
|
88
|
+
* B's own tip silently never happens." The 400 is what prevents it.
|
|
89
|
+
*
|
|
90
|
+
* Separately, the key is substringed into the orchestrator `externalId`
|
|
91
|
+
* (`blk<NN><appBlockId><key>`), whose own contract is `^[A-Za-z0-9_-]+$` with a
|
|
92
|
+
* **128**-char ceiling — a colon is outside that charset too. The host's source
|
|
93
|
+
* records that an earlier revision assumed a colon-delimited id and that it
|
|
94
|
+
* "would have 400'd every keyed block generation submit."
|
|
95
|
+
*
|
|
96
|
+
* ## 🔴 WHERE THE 64 COMES FROM
|
|
97
|
+
*
|
|
98
|
+
* It is DERIVED, not chosen. The host composes the orchestrator `externalId` as
|
|
99
|
+
* `blk<NN><appBlockId><key>`; `ORCHESTRATOR_EXTERNAL_ID_MAX` is 128. 64 keeps the
|
|
100
|
+
* worst-case composition inside 128. Do not "relax" it here — the SDK is not the
|
|
101
|
+
* authority, and a key this module accepts but the host rejects is the exact
|
|
102
|
+
* defect above, reintroduced.
|
|
103
|
+
*
|
|
104
|
+
* ## 🔴 REFUSE, NEVER REWRITE
|
|
105
|
+
*
|
|
106
|
+
* Nothing in this module sanitises, truncates or normalises a key, and no
|
|
107
|
+
* consumer may. An idempotency key is an **identity**: silently rewriting a
|
|
108
|
+
* caller's key breaks the property the key exists to provide — two distinct
|
|
109
|
+
* logical submits could collapse onto one slot (one charge for two intended
|
|
110
|
+
* operations), or a retry could be rewritten differently from the first attempt
|
|
111
|
+
* and mint a SECOND reservation for one logical operation. Both are money bugs,
|
|
112
|
+
* and both are quieter than a rejection. A malformed key is a defect in the
|
|
113
|
+
* calling block's code; the only safe response is to refuse it loudly, before
|
|
114
|
+
* anything is sent.
|
|
115
|
+
*/
|
|
116
|
+
/**
|
|
117
|
+
* The host's charset + length rule for a money-POST idempotency key, vendored
|
|
118
|
+
* verbatim from `block-gen-idempotency.ts:77`.
|
|
119
|
+
*
|
|
120
|
+
* 🔴 NO `g` FLAG, DELIBERATELY. A `g`-flagged regex carries `lastIndex` across
|
|
121
|
+
* calls, so repeated `.test()` on the SAME instance alternates true/false for an
|
|
122
|
+
* identical input — which on this surface would mean a key accepted on one
|
|
123
|
+
* submit and refused on its retry.
|
|
124
|
+
*/
|
|
125
|
+
export const BLOCK_IDEMPOTENCY_KEY_REGEX = /^[A-Za-z0-9_-]{1,64}$/;
|
|
126
|
+
/**
|
|
127
|
+
* The host's length ceiling, named so a caller composing a key can budget
|
|
128
|
+
* against it instead of rediscovering 64 from a rejection.
|
|
129
|
+
*
|
|
130
|
+
* Kept as its own constant rather than parsed back out of the regex: this is the
|
|
131
|
+
* number a caller does arithmetic with, and the two are pinned to each other by
|
|
132
|
+
* `blockIdempotencyKeyRejection`'s own tests.
|
|
133
|
+
*/
|
|
134
|
+
export const BLOCK_IDEMPOTENCY_KEY_MAX_LENGTH = 64;
|
|
135
|
+
/**
|
|
136
|
+
* Does `value` clear the host's rule?
|
|
137
|
+
*
|
|
138
|
+
* Returns `false` for a non-string (including `null`/`undefined`) rather than
|
|
139
|
+
* throwing: callers reach this with untrusted input from a block's own code, and
|
|
140
|
+
* a type-level `string` is not a runtime guarantee at a package boundary.
|
|
141
|
+
*
|
|
142
|
+
* 🔴 This is the predicate EVERY consumer must call. Re-spelling the regex at a
|
|
143
|
+
* call site is what produced the defect in this module's header.
|
|
144
|
+
*
|
|
145
|
+
* 🔴 RETURNS `boolean`, NOT A TYPE PREDICATE (`value is string`), DELIBERATELY.
|
|
146
|
+
* A predicate signature narrows the FALSE branch to `Exclude<string, string>` =
|
|
147
|
+
* `never` for an already-`string` argument, which silently makes the rejection
|
|
148
|
+
* path below uncompilable — and in other callers would make a legitimate
|
|
149
|
+
* else-branch look like dead code. The narrowing bought nothing: no caller needs
|
|
150
|
+
* `unknown` → `string` narrowing from this function.
|
|
151
|
+
*/
|
|
152
|
+
export function isValidBlockIdempotencyKey(value) {
|
|
153
|
+
return typeof value === 'string' && BLOCK_IDEMPOTENCY_KEY_REGEX.test(value);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* A developer-facing explanation of WHY `value` was refused, or `null` when it
|
|
157
|
+
* is valid.
|
|
158
|
+
*
|
|
159
|
+
* 🔴 DEVELOPER-FACING, NEVER VIEWER-FACING. A malformed idempotency key is a bug
|
|
160
|
+
* in the block's own code — no retry fixes it and no viewer can act on it, so
|
|
161
|
+
* rendering this string into UI tells the wrong person. It names the offending
|
|
162
|
+
* value because that is the single most useful fact for the person who has to
|
|
163
|
+
* fix it, and because the key is the block's own construction, not viewer data.
|
|
164
|
+
*
|
|
165
|
+
* The returned text is not a contract — branch on
|
|
166
|
+
* {@link isValidBlockIdempotencyKey}, never on this wording.
|
|
167
|
+
*
|
|
168
|
+
* Reasons are reported SPECIFICALLY rather than as one generic sentence: "it has
|
|
169
|
+
* a colon" and "it is 71 characters" lead to different fixes, and the colon case
|
|
170
|
+
* is the one that looks most like valid input.
|
|
171
|
+
*/
|
|
172
|
+
export function blockIdempotencyKeyRejection(value) {
|
|
173
|
+
if (typeof value !== 'string') {
|
|
174
|
+
return `the idempotency key must be a string, got ${value === null ? 'null' : typeof value}`;
|
|
175
|
+
}
|
|
176
|
+
if (isValidBlockIdempotencyKey(value))
|
|
177
|
+
return null;
|
|
178
|
+
const why = [];
|
|
179
|
+
if (value.length === 0) {
|
|
180
|
+
why.push('it is empty');
|
|
181
|
+
}
|
|
182
|
+
else if (value.length > BLOCK_IDEMPOTENCY_KEY_MAX_LENGTH) {
|
|
183
|
+
why.push(`it is ${value.length} characters (the host allows at most ${BLOCK_IDEMPOTENCY_KEY_MAX_LENGTH})`);
|
|
184
|
+
}
|
|
185
|
+
// Report the offending characters, de-duplicated and in first-appearance
|
|
186
|
+
// order, so a key with four colons does not print four identical complaints.
|
|
187
|
+
const offending = [...new Set(value.split('').filter((ch) => !/[A-Za-z0-9_-]/.test(ch)))];
|
|
188
|
+
if (offending.length > 0) {
|
|
189
|
+
const shown = offending.map((ch) => JSON.stringify(ch)).join(', ');
|
|
190
|
+
why.push(`it contains ${offending.length === 1 ? 'the character' : 'the characters'} ${shown}` +
|
|
191
|
+
` (the host allows letters, digits, underscore and hyphen only` +
|
|
192
|
+
(offending.includes(':')
|
|
193
|
+
? // Called out by name: a colon is BOTH the most natural delimiter for a
|
|
194
|
+
// composite key and the one character the host's redis-key injectivity
|
|
195
|
+
// argument depends on excluding. See this module's header.
|
|
196
|
+
`; a colon is specifically excluded because the host composes its` +
|
|
197
|
+
` per-(user, app, key) rate-limit and dedupe keys with ':' as the delimiter`
|
|
198
|
+
: '') +
|
|
199
|
+
`)`);
|
|
200
|
+
}
|
|
201
|
+
// Unreachable while the regex and this function agree — but a regex change
|
|
202
|
+
// that this function does not mirror must not produce an EMPTY reason, which
|
|
203
|
+
// would read as "refused for no stated cause".
|
|
204
|
+
if (why.length === 0) {
|
|
205
|
+
why.push(`it does not match the host's required pattern ${String(BLOCK_IDEMPOTENCY_KEY_REGEX)}`);
|
|
206
|
+
}
|
|
207
|
+
return `${why.join(', and ')}. Offending value: ${JSON.stringify(value)}`;
|
|
208
|
+
}
|
|
209
|
+
//# sourceMappingURL=idempotency.js.map
|
package/dist/blocks/index.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ export type { BlockScope, BlockScopeKey, BlockCategory } from './scopes.js';
|
|
|
28
28
|
export { BrowsingLevel, SFW_LEVELS, NSFW_LEVELS, isSfwCeiling, isLevelAllowed, effectiveBrowsingCeiling, } from './browsingLevel.js';
|
|
29
29
|
export type { BrowsingLevelKey, BrowsingLevelBit, ColorDomain } from './browsingLevel.js';
|
|
30
30
|
export { APP_STORAGE_MAX_VALUE_BYTES, APP_STORAGE_MAX_BYTES, APP_STORAGE_MAX_ROWS, } from './appStorageLimits.js';
|
|
31
|
+
export { BLOCK_IDEMPOTENCY_KEY_REGEX, BLOCK_IDEMPOTENCY_KEY_MAX_LENGTH, isValidBlockIdempotencyKey, blockIdempotencyKeyRejection, } from './idempotency.js';
|
|
31
32
|
/**
|
|
32
33
|
* {@link classifyAppStorageError} — the matcher a block branches on — plus the
|
|
33
34
|
* four rejection messages a MOCK HOST has to emit. The wire carries a
|
package/dist/blocks/index.js
CHANGED
|
@@ -31,6 +31,7 @@ export { BlockManifestError } from './manifestError.js';
|
|
|
31
31
|
export { BLOCK_SCOPES, BLOCK_SCOPE_PATTERN, BLOCK_CATEGORIES, BLOCK_TAGLINE_MAX_LENGTH, } from './scopes.js';
|
|
32
32
|
export { BrowsingLevel, SFW_LEVELS, NSFW_LEVELS, isSfwCeiling, isLevelAllowed, effectiveBrowsingCeiling, } from './browsingLevel.js';
|
|
33
33
|
export { APP_STORAGE_MAX_VALUE_BYTES, APP_STORAGE_MAX_BYTES, APP_STORAGE_MAX_ROWS, } from './appStorageLimits.js';
|
|
34
|
+
export { BLOCK_IDEMPOTENCY_KEY_REGEX, BLOCK_IDEMPOTENCY_KEY_MAX_LENGTH, isValidBlockIdempotencyKey, blockIdempotencyKeyRejection, } from './idempotency.js';
|
|
34
35
|
/**
|
|
35
36
|
* {@link classifyAppStorageError} — the matcher a block branches on — plus the
|
|
36
37
|
* four rejection messages a MOCK HOST has to emit. The wire carries a
|
package/dist/blocks/types.d.ts
CHANGED
|
@@ -1453,8 +1453,24 @@ export interface BlockManifestGood {
|
|
|
1453
1453
|
* What the entitlement grants. `app_unlock` marks a one-time unlock of the app
|
|
1454
1454
|
* itself; it is RECORDED identically today and the platform does not yet act
|
|
1455
1455
|
* on it, so declaring it buys nothing unless you intend that later behaviour.
|
|
1456
|
+
*
|
|
1457
|
+
* 🔴 `app_unlock` carries THREE extra rules the platform validator enforces and
|
|
1458
|
+
* this schema does not express: `priceBuzz` at most 5000 (not the 50000 the
|
|
1459
|
+
* `priceBuzz` bound allows), at most ONE `app_unlock` good per manifest, and a
|
|
1460
|
+
* mandatory `justification`. Local validation passing is necessary, not
|
|
1461
|
+
* sufficient — see the canonical schema's `kind` description.
|
|
1456
1462
|
*/
|
|
1457
1463
|
kind?: 'good' | 'app_unlock';
|
|
1464
|
+
/**
|
|
1465
|
+
* Why this good exists, shown to the moderator at review and never to the
|
|
1466
|
+
* viewer. REQUIRED when `kind` is `'app_unlock'`: adding an unlock turns a free
|
|
1467
|
+
* app into a paid one, and because an unlock does not require the sensitive
|
|
1468
|
+
* `goods:purchase:self` scope, this is what makes that a reviewed decision
|
|
1469
|
+
* rather than a diff nobody was pointed at. Optional for an ordinary good.
|
|
1470
|
+
* Review metadata only — unlike `payload` it is never copied onto an
|
|
1471
|
+
* entitlement, and the platform does not verify the claim. At most 500 chars.
|
|
1472
|
+
*/
|
|
1473
|
+
justification?: string;
|
|
1458
1474
|
/** Opaque app payload, carried verbatim onto the entitlement. Never interpreted by the platform. */
|
|
1459
1475
|
payload?: Record<string, unknown>;
|
|
1460
1476
|
}
|
package/package.json
CHANGED