@durable-streams/server 0.3.1 → 0.3.3
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/index.cjs +1344 -266
- package/dist/index.d.cts +258 -2
- package/dist/index.d.ts +258 -2
- package/dist/index.js +1391 -318
- package/package.json +4 -4
- package/src/crypto.ts +217 -0
- package/src/file-store.ts +239 -144
- package/src/glob.ts +70 -0
- package/src/index.ts +14 -0
- package/src/log.ts +56 -0
- package/src/server.ts +96 -40
- package/src/store.ts +66 -10
- package/src/subscription-manager.ts +882 -0
- package/src/subscription-routes.ts +504 -0
- package/src/subscription-types.ts +80 -0
- package/src/types.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@durable-streams/server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Node.js reference server implementation for Durable Streams",
|
|
5
5
|
"author": "Durable Stream contributors",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -39,15 +39,15 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@neophi/sieve-cache": "^1.0.0",
|
|
41
41
|
"lmdb": "^3.3.0",
|
|
42
|
-
"@durable-streams/client": "0.2.
|
|
43
|
-
"@durable-streams/state": "0.2.
|
|
42
|
+
"@durable-streams/client": "0.2.4",
|
|
43
|
+
"@durable-streams/state": "0.2.7"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^22.0.0",
|
|
47
47
|
"tsdown": "^0.9.0",
|
|
48
48
|
"typescript": "^5.0.0",
|
|
49
49
|
"vitest": "^4.0.0",
|
|
50
|
-
"@durable-streams/server-conformance-tests": "0.3.
|
|
50
|
+
"@durable-streams/server-conformance-tests": "0.3.2"
|
|
51
51
|
},
|
|
52
52
|
"files": [
|
|
53
53
|
"dist",
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cryptographic utilities for webhook signatures and callback tokens.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createHash,
|
|
7
|
+
createHmac,
|
|
8
|
+
createPublicKey,
|
|
9
|
+
generateKeyPairSync,
|
|
10
|
+
randomBytes,
|
|
11
|
+
sign,
|
|
12
|
+
timingSafeEqual,
|
|
13
|
+
verify as verifySignature,
|
|
14
|
+
} from "node:crypto"
|
|
15
|
+
import type { JsonWebKey as NodeJsonWebKey } from "node:crypto"
|
|
16
|
+
|
|
17
|
+
export interface WebhookPublicJwk {
|
|
18
|
+
kty: `OKP`
|
|
19
|
+
crv: `Ed25519`
|
|
20
|
+
x: string
|
|
21
|
+
kid: string
|
|
22
|
+
use: `sig`
|
|
23
|
+
alg: `EdDSA`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WebhookJwks {
|
|
27
|
+
keys: Array<WebhookPublicJwk>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generate a unique wake ID.
|
|
32
|
+
*/
|
|
33
|
+
export function generateWakeId(): string {
|
|
34
|
+
return `w_${randomBytes(12).toString(`hex`)}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const WEBHOOK_KEYPAIR = generateKeyPairSync(`ed25519`)
|
|
38
|
+
const WEBHOOK_PUBLIC_JWK = buildWebhookPublicJwk()
|
|
39
|
+
|
|
40
|
+
function buildWebhookPublicJwk(): WebhookPublicJwk {
|
|
41
|
+
const exported = WEBHOOK_KEYPAIR.publicKey.export({ format: `jwk` }) as {
|
|
42
|
+
kty?: string
|
|
43
|
+
crv?: string
|
|
44
|
+
x?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (exported.kty !== `OKP` || exported.crv !== `Ed25519` || !exported.x) {
|
|
48
|
+
throw new Error(`Failed to export Ed25519 webhook signing key`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const thumbprintInput = JSON.stringify({
|
|
52
|
+
crv: exported.crv,
|
|
53
|
+
kty: exported.kty,
|
|
54
|
+
x: exported.x,
|
|
55
|
+
})
|
|
56
|
+
const kid = `ds_${createHash(`sha256`)
|
|
57
|
+
.update(thumbprintInput)
|
|
58
|
+
.digest(`base64url`)}`
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
kty: `OKP`,
|
|
62
|
+
crv: `Ed25519`,
|
|
63
|
+
x: exported.x,
|
|
64
|
+
kid,
|
|
65
|
+
use: `sig`,
|
|
66
|
+
alg: `EdDSA`,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getWebhookSigningKeyId(): string {
|
|
71
|
+
return WEBHOOK_PUBLIC_JWK.kid
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getWebhookJwks(): WebhookJwks {
|
|
75
|
+
return {
|
|
76
|
+
keys: [{ ...WEBHOOK_PUBLIC_JWK }],
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Sign a webhook payload for the Webhook-Signature header.
|
|
82
|
+
* Format: t=<timestamp>,kid=<key_id>,ed25519=<base64url_signature>
|
|
83
|
+
*/
|
|
84
|
+
export function signWebhookPayload(body: string): string {
|
|
85
|
+
const timestamp = Math.floor(Date.now() / 1000)
|
|
86
|
+
const payload = `${timestamp}.${body}`
|
|
87
|
+
const signature = sign(
|
|
88
|
+
null,
|
|
89
|
+
Buffer.from(payload),
|
|
90
|
+
WEBHOOK_KEYPAIR.privateKey
|
|
91
|
+
).toString(`base64url`)
|
|
92
|
+
return `t=${timestamp},kid=${WEBHOOK_PUBLIC_JWK.kid},ed25519=${signature}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Verify a webhook signature.
|
|
97
|
+
*/
|
|
98
|
+
export function verifyWebhookSignature(
|
|
99
|
+
body: string,
|
|
100
|
+
signatureHeader: string,
|
|
101
|
+
jwks: WebhookJwks = getWebhookJwks(),
|
|
102
|
+
toleranceSeconds = 300
|
|
103
|
+
): boolean {
|
|
104
|
+
const match = signatureHeader.match(
|
|
105
|
+
/^t=(\d+),kid=([^,]+),ed25519=([A-Za-z0-9_-]+)$/
|
|
106
|
+
)
|
|
107
|
+
if (!match) return false
|
|
108
|
+
|
|
109
|
+
const [, timestamp, kid, signature] = match
|
|
110
|
+
const ts = parseInt(timestamp!, 10)
|
|
111
|
+
|
|
112
|
+
const now = Math.floor(Date.now() / 1000)
|
|
113
|
+
if (Math.abs(now - ts) > toleranceSeconds) return false
|
|
114
|
+
|
|
115
|
+
const jwk = jwks.keys.find((key) => key.kid === kid)
|
|
116
|
+
if (!jwk) return false
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const publicKey = createPublicKey({
|
|
120
|
+
key: jwk as unknown as NodeJsonWebKey,
|
|
121
|
+
format: `jwk`,
|
|
122
|
+
})
|
|
123
|
+
return verifySignature(
|
|
124
|
+
null,
|
|
125
|
+
Buffer.from(`${timestamp}.${body}`),
|
|
126
|
+
publicKey,
|
|
127
|
+
Buffer.from(signature!, `base64url`)
|
|
128
|
+
)
|
|
129
|
+
} catch {
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Token signing key — generated per server instance
|
|
135
|
+
const TOKEN_KEY = randomBytes(32)
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Generate a signed callback token.
|
|
139
|
+
* Token format: base64url(json_payload).base64url(hmac_signature)
|
|
140
|
+
* Payload: { consumer_id, epoch, exp }
|
|
141
|
+
*/
|
|
142
|
+
export function generateCallbackToken(
|
|
143
|
+
consumerId: string,
|
|
144
|
+
epoch: number
|
|
145
|
+
): string {
|
|
146
|
+
const payload = {
|
|
147
|
+
sub: consumerId,
|
|
148
|
+
epoch,
|
|
149
|
+
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour TTL
|
|
150
|
+
jti: randomBytes(8).toString(`hex`),
|
|
151
|
+
}
|
|
152
|
+
const payloadStr = Buffer.from(JSON.stringify(payload)).toString(`base64url`)
|
|
153
|
+
const sig = createHmac(`sha256`, TOKEN_KEY)
|
|
154
|
+
.update(payloadStr)
|
|
155
|
+
.digest(`base64url`)
|
|
156
|
+
return `${payloadStr}.${sig}`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Seconds before expiry at which a token should be refreshed. */
|
|
160
|
+
const TOKEN_REFRESH_THRESHOLD = 300 // 5 minutes
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Validate a callback token. Returns the decoded payload or null.
|
|
164
|
+
* On success, includes `exp` (unix seconds) so callers can decide
|
|
165
|
+
* whether the token needs refreshing.
|
|
166
|
+
*/
|
|
167
|
+
export function validateCallbackToken(
|
|
168
|
+
token: string,
|
|
169
|
+
consumerId: string
|
|
170
|
+
):
|
|
171
|
+
| { valid: true; exp: number; epoch: number }
|
|
172
|
+
| { valid: false; code: `TOKEN_INVALID` | `TOKEN_EXPIRED` } {
|
|
173
|
+
const parts = token.split(`.`)
|
|
174
|
+
if (parts.length !== 2) {
|
|
175
|
+
return { valid: false, code: `TOKEN_INVALID` }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const [payloadStr, sig] = parts
|
|
179
|
+
|
|
180
|
+
const expectedSig = createHmac(`sha256`, TOKEN_KEY)
|
|
181
|
+
.update(payloadStr!)
|
|
182
|
+
.digest(`base64url`)
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
if (!timingSafeEqual(Buffer.from(sig!), Buffer.from(expectedSig))) {
|
|
186
|
+
return { valid: false, code: `TOKEN_INVALID` }
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
return { valid: false, code: `TOKEN_INVALID` }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let payload: { sub: string; epoch: number; exp: number }
|
|
193
|
+
try {
|
|
194
|
+
payload = JSON.parse(Buffer.from(payloadStr!, `base64url`).toString())
|
|
195
|
+
} catch {
|
|
196
|
+
return { valid: false, code: `TOKEN_INVALID` }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (payload.sub !== consumerId) {
|
|
200
|
+
return { valid: false, code: `TOKEN_INVALID` }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const now = Math.floor(Date.now() / 1000)
|
|
204
|
+
if (now > payload.exp) {
|
|
205
|
+
return { valid: false, code: `TOKEN_EXPIRED` }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { valid: true, exp: payload.exp, epoch: payload.epoch }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Check whether a token is close enough to expiry that it should be refreshed.
|
|
213
|
+
*/
|
|
214
|
+
export function tokenNeedsRefresh(exp: number): boolean {
|
|
215
|
+
const now = Math.floor(Date.now() / 1000)
|
|
216
|
+
return exp - now <= TOKEN_REFRESH_THRESHOLD
|
|
217
|
+
}
|