@ciromaciel/contract 1.0.4 → 1.1.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/README.md +17 -0
- package/package.json +3 -2
- package/src/plan/notice.js +7 -5
- package/src/webhooks.js +119 -0
package/README.md
CHANGED
|
@@ -92,6 +92,23 @@ the prefix, the slugs and the wording of the notice; the cache, the fail-open,
|
|
|
92
92
|
the product-prefix check and the choice between `PLAN_LIMIT` and
|
|
93
93
|
`SUBSCRIPTION_REQUIRED` are the same reasoning everywhere.
|
|
94
94
|
|
|
95
|
+
### `@ciromaciel/contract/webhooks`
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
import { webhookHeaders, verifyWebhook, generateSigningSecret } from '@ciromaciel/contract/webhooks'
|
|
99
|
+
|
|
100
|
+
const headers = await webhookHeaders(secret, { id: event.id, body }) // x-ccm-id, x-ccm-timestamp, x-ccm-signature
|
|
101
|
+
const result = await verifyWebhook([secret, previousSecret], { id, timestamp, signature, body })
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
One protocol for every webhook of the stack: Standard Webhooks with the `x-ccm`
|
|
105
|
+
prefix. The signature is `v1,<base64 HMAC-SHA256 of "id.timestamp.body">`, the
|
|
106
|
+
key is the base64 after `whsec_`, and a timestamp more than five minutes off is
|
|
107
|
+
refused. `verifyWebhook` takes several secrets so a rotation has a grace period,
|
|
108
|
+
and answers with a reason (`stale_timestamp`, `bad_signature`, …) instead of a
|
|
109
|
+
bare boolean. Also exports `signWebhook`, `secretPrefixOf` and
|
|
110
|
+
`SIGNATURE_TOLERANCE_SECONDS`.
|
|
111
|
+
|
|
95
112
|
## Links
|
|
96
113
|
|
|
97
114
|
- [Website](https://ciromaciel.click)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ciromaciel/contract",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A single response envelope, a closed catalogue of error codes, and helpers that make answering outside it impossible.",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
".": "./src/index.js",
|
|
14
14
|
"./codes": "./src/codes.js",
|
|
15
15
|
"./schemas": "./src/schemas.js",
|
|
16
|
-
"./plan": "./src/plan/index.js"
|
|
16
|
+
"./plan": "./src/plan/index.js",
|
|
17
|
+
"./webhooks": "./src/webhooks.js"
|
|
17
18
|
},
|
|
18
19
|
"scripts": {
|
|
19
20
|
"test": "bun test tests/"
|
package/src/plan/notice.js
CHANGED
|
@@ -38,11 +38,13 @@ export async function getPlanNotice(gate, cfg, ownerId, copy = {}, current = nul
|
|
|
38
38
|
* NO PLAN IS NOT A PROBLEM TO REPORT. Since the free floor became one
|
|
39
39
|
* resource (2026-09-17), an owner with no plan is simply on the free tier,
|
|
40
40
|
* and telling them "your subscription is not active" reads as a billing
|
|
41
|
-
* failure they never had.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* failure they never had. Using what the free tier gives — even all of it —
|
|
42
|
+
* is not a problem either: the refusal on the next creation already offers
|
|
43
|
+
* the upgrade at the right moment. The notice speaks only when the owner has
|
|
44
|
+
* MORE than the free tier covers (a lapsed plan left resources behind), and
|
|
45
|
+
* it can only know that from `current` — without it there is no notice.
|
|
44
46
|
*/
|
|
45
|
-
if (typeof current !== 'number' || limit === null || current
|
|
47
|
+
if (typeof current !== 'number' || limit === null || current <= limit) {
|
|
46
48
|
return { state: 'free', plan: ent.plan, limit, message: null }
|
|
47
49
|
}
|
|
48
50
|
const product = copy.product || 'este produto'
|
|
@@ -52,7 +54,7 @@ export async function getPlanNotice(gate, cfg, ownerId, copy = {}, current = nul
|
|
|
52
54
|
state: 'none',
|
|
53
55
|
plan: ent.plan,
|
|
54
56
|
limit,
|
|
55
|
-
message: `O plano gratuito do ${product}
|
|
57
|
+
message: `O plano gratuito do ${product} inclui ${limit} e você tem ${current}. ${keeps} — ${frozen}. Assine para voltar a criar.`,
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
package/src/webhooks.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @ciromaciel/contract/webhooks — one protocol for every webhook of the stack
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// import { webhookHeaders, verifyWebhook } from '@ciromaciel/contract/webhooks'
|
|
6
|
+
//
|
|
7
|
+
// Standard Webhooks (the scheme OpenAI and Anthropic use) with our `x-ccm`
|
|
8
|
+
// prefix. Three headers travel with the body:
|
|
9
|
+
//
|
|
10
|
+
// x-ccm-id: the event's own id (resending it is a no-op)
|
|
11
|
+
// x-ccm-timestamp: unix seconds; more than 5 minutes off is refused
|
|
12
|
+
// x-ccm-signature: `v1,<base64 HMAC-SHA256 of "id.timestamp.body">`,
|
|
13
|
+
// several separated by spaces while a secret rotates
|
|
14
|
+
//
|
|
15
|
+
// The HMAC key is the secret after `whsec_`, base64-decoded, exactly as the
|
|
16
|
+
// Standard Webhooks libraries do, so any of them signs for us once the header
|
|
17
|
+
// names are ours. The id and the timestamp are inside the signed string: a
|
|
18
|
+
// captured request cannot be replayed with a fresh timestamp or passed off as
|
|
19
|
+
// another event.
|
|
20
|
+
//
|
|
21
|
+
// WHY IT LIVES HERE. People wrote it first, to VERIFY what its sources send.
|
|
22
|
+
// Videos is the first product that EMITS in this protocol, twice (to People
|
|
23
|
+
// and to a customer's own endpoint), and Auth, Hoster and Payments will emit
|
|
24
|
+
// when they gain webhooks. One file, or four copies that drift — the plan gate
|
|
25
|
+
// already paid for that lesson.
|
|
26
|
+
//
|
|
27
|
+
// IT RUNS ON BOTH RUNTIMES: only Web Crypto, TextEncoder and btoa/atob.
|
|
28
|
+
|
|
29
|
+
/** How far a signature's timestamp may drift from now, in seconds. */
|
|
30
|
+
export const SIGNATURE_TOLERANCE_SECONDS = 300
|
|
31
|
+
|
|
32
|
+
const encoder = new TextEncoder()
|
|
33
|
+
const toBase64 = bytes => btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
|
34
|
+
const fromBase64 = value => Uint8Array.from(atob(value), c => c.charCodeAt(0))
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A new signing secret: `whsec_` and 32 random bytes in base64, the Standard
|
|
38
|
+
* Webhooks shape. Whoever finds it in a config knows at a glance what it is.
|
|
39
|
+
*/
|
|
40
|
+
export function generateSigningSecret() {
|
|
41
|
+
return `whsec_${toBase64(crypto.getRandomValues(new Uint8Array(32)))}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The part of a secret that may be shown again: enough to recognise it. */
|
|
45
|
+
export const secretPrefixOf = secret => String(secret).slice(0, 12)
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The HMAC key of a secret: the base64 after `whsec_`. A secret that is not
|
|
49
|
+
* base64 (hex from before the switch is valid base64 too) falls back to its
|
|
50
|
+
* raw bytes, so both sides still derive the same key.
|
|
51
|
+
*/
|
|
52
|
+
function keyBytes(secret) {
|
|
53
|
+
try {
|
|
54
|
+
return fromBase64(String(secret).replace(/^whsec_/, ''))
|
|
55
|
+
} catch {
|
|
56
|
+
return encoder.encode(String(secret))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function hmacBase64(secret, payload) {
|
|
61
|
+
const key = await crypto.subtle.importKey('raw', keyBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
|
62
|
+
return toBase64(await crypto.subtle.sign('HMAC', key, encoder.encode(payload)))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The `x-ccm-signature` value for one delivery. */
|
|
66
|
+
export async function signWebhook(secret, { id, timestamp, body }) {
|
|
67
|
+
return `v1,${await hmacBase64(secret, `${id}.${timestamp}.${body}`)}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The three headers of one delivery, ready to send.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} secret `whsec_…`
|
|
74
|
+
* @param {{id: string, body: string, timestamp?: number}} delivery
|
|
75
|
+
* @returns {Promise<Record<string, string>>}
|
|
76
|
+
*/
|
|
77
|
+
export async function webhookHeaders(secret, { id, body, timestamp = Math.floor(Date.now() / 1000) }) {
|
|
78
|
+
return { 'x-ccm-id': String(id), 'x-ccm-timestamp': String(timestamp), 'x-ccm-signature': await signWebhook(secret, { id, timestamp, body }) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Compares without leaking, through timing, how many leading characters matched. */
|
|
82
|
+
function timingSafeEqual(a, b) {
|
|
83
|
+
if (a.length !== b.length) return false
|
|
84
|
+
let diff = 0
|
|
85
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
86
|
+
return diff === 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Is this body really from a holder of one of `secrets`? More than one while a
|
|
91
|
+
* rotation's grace lasts: the new secret and the one it replaced.
|
|
92
|
+
*
|
|
93
|
+
* Returns a reason instead of a boolean so the refusal can say what to fix;
|
|
94
|
+
* the reasons never echo the expected signature.
|
|
95
|
+
*
|
|
96
|
+
* @param {string[]} secrets
|
|
97
|
+
* @param {{id: string|null, timestamp: string|null, signature: string|null, body: string}} delivery
|
|
98
|
+
* @param {number} [now] unix seconds
|
|
99
|
+
* @returns {Promise<{ok: true} | {ok: false, reason: 'missing_headers'|'malformed_timestamp'|'stale_timestamp'|'malformed_signature'|'bad_signature'}>}
|
|
100
|
+
*/
|
|
101
|
+
export async function verifyWebhook(secrets, { id, timestamp, signature, body }, now = Math.floor(Date.now() / 1000)) {
|
|
102
|
+
if (!id || !timestamp || !signature) return { ok: false, reason: 'missing_headers' }
|
|
103
|
+
const seconds = Number(timestamp)
|
|
104
|
+
if (!/^\d+$/.test(String(timestamp)) || !Number.isFinite(seconds)) return { ok: false, reason: 'malformed_timestamp' }
|
|
105
|
+
if (Math.abs(now - seconds) > SIGNATURE_TOLERANCE_SECONDS) return { ok: false, reason: 'stale_timestamp' }
|
|
106
|
+
|
|
107
|
+
const offered = String(signature)
|
|
108
|
+
.split(' ')
|
|
109
|
+
.map(part => part.trim())
|
|
110
|
+
.filter(part => part.startsWith('v1,'))
|
|
111
|
+
.map(part => part.slice(3))
|
|
112
|
+
if (!offered.length) return { ok: false, reason: 'malformed_signature' }
|
|
113
|
+
|
|
114
|
+
for (const secret of secrets) {
|
|
115
|
+
const expected = await hmacBase64(secret, `${id}.${seconds}.${body}`)
|
|
116
|
+
if (offered.some(value => timingSafeEqual(expected, value))) return { ok: true }
|
|
117
|
+
}
|
|
118
|
+
return { ok: false, reason: 'bad_signature' }
|
|
119
|
+
}
|