@volter/world-core 2.0.36 → 3.0.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 +4 -5
- package/app-route.cjs +12 -6
- package/app-route.d.cts +1 -1
- package/dist/app-route.cjs +12 -6
- package/dist/app-route.d.cts +1 -1
- package/dist/generated/pack-facts.json +1410 -3069
- package/dist/inject.cjs +64 -9
- package/dist/pack-facts.cjs +44 -0
- package/dist/src/actions.d.ts +3 -3
- package/dist/src/actions.js +22 -16
- package/dist/src/ancestry.d.ts +14 -2
- package/dist/src/ancestry.js +92 -2
- package/dist/src/anthropic-wire.d.ts +39 -0
- package/dist/src/anthropic-wire.js +136 -0
- package/dist/src/bytes.d.ts +7 -0
- package/dist/src/bytes.js +35 -0
- package/dist/src/changeset.d.ts +1 -1
- package/dist/src/changeset.js +0 -0
- package/dist/src/clickhouse/index.d.ts +3 -0
- package/dist/src/clickhouse/index.js +6 -0
- package/dist/src/clickhouse/sql.d.ts +233 -0
- package/dist/src/clickhouse/sql.js +4329 -0
- package/dist/src/clickhouse/types.d.ts +18 -0
- package/dist/src/clickhouse/types.js +47 -0
- package/dist/src/clickhouse/values.d.ts +146 -0
- package/dist/src/clickhouse/values.js +858 -0
- package/dist/src/client-bundle.js +2 -3
- package/dist/src/cors.d.ts +15 -0
- package/dist/src/cors.js +31 -0
- package/dist/src/derived-core.d.ts +487 -24
- package/dist/src/derived-core.js +788 -144
- package/dist/src/derived-real.d.ts +13 -0
- package/dist/src/derived-real.js +518 -0
- package/dist/src/derived.d.ts +35 -1
- package/dist/src/derived.js +61 -9
- package/dist/src/emit.js +1 -2
- package/dist/src/events.d.ts +206 -0
- package/dist/src/events.js +341 -0
- package/dist/src/executor.d.ts +3 -0
- package/dist/src/executor.js +19 -2
- package/dist/src/file-response.d.ts +6 -0
- package/dist/src/file-response.js +30 -0
- package/dist/src/fork.js +3 -2
- package/dist/src/git/history.d.ts +7 -0
- package/dist/src/git/history.js +24 -0
- package/dist/src/git/index.d.ts +1 -0
- package/dist/src/git/index.js +1 -0
- package/dist/src/git/lfs.d.ts +28 -0
- package/dist/src/git/lfs.js +66 -0
- package/dist/src/git/objects.js +3 -8
- package/dist/src/git/smart-http.d.ts +3 -1
- package/dist/src/git/smart-http.js +67 -6
- package/dist/src/graphql-wire.d.ts +29 -0
- package/dist/src/graphql-wire.js +101 -0
- package/dist/src/grpc-wire.d.ts +67 -0
- package/dist/src/grpc-wire.js +170 -0
- package/dist/src/h2.d.ts +40 -0
- package/dist/src/h2.js +656 -0
- package/dist/src/head.d.ts +32 -3
- package/dist/src/head.js +161 -40
- package/dist/src/history.d.ts +1 -1
- package/dist/src/history.js +6 -6
- package/dist/src/hpack.json +1 -0
- package/dist/src/index.d.ts +64 -75
- package/dist/src/index.js +58 -101
- package/dist/src/log.js +28 -19
- package/dist/src/machines.d.ts +50 -0
- package/dist/src/machines.js +151 -0
- package/dist/src/managed-database.d.ts +86 -0
- package/dist/src/managed-database.js +283 -0
- package/dist/src/multipart.d.ts +11 -0
- package/dist/src/multipart.js +51 -0
- package/dist/src/observe.d.ts +15 -5
- package/dist/src/observe.js +23 -9
- package/dist/src/openai-wire.d.ts +108 -0
- package/dist/src/openai-wire.js +337 -0
- package/dist/src/pack-assets.d.ts +3 -4
- package/dist/src/pack-assets.js +15 -10
- package/dist/src/pack-fetch.d.ts +77 -0
- package/dist/src/pack-fetch.js +449 -0
- package/dist/src/pack-paths.d.ts +12 -0
- package/dist/src/pack-paths.js +86 -0
- package/dist/src/packRegistry.d.ts +69 -162
- package/dist/src/packRegistry.js +55 -20
- package/dist/src/people.d.ts +13 -0
- package/dist/src/people.js +18 -0
- package/dist/src/placeholder-image.d.ts +5 -0
- package/dist/src/placeholder-image.js +114 -0
- package/dist/src/protobuf.d.ts +28 -0
- package/dist/src/protobuf.js +332 -0
- package/dist/src/redis/engine.js +1 -1
- package/dist/src/request-scope.d.ts +1 -1
- package/dist/src/request-scope.js +6 -4
- package/dist/src/resource-blob.d.ts +5 -0
- package/dist/src/resource-blob.js +11 -0
- package/dist/src/runtime.d.ts +85 -0
- package/dist/src/runtime.js +104 -0
- package/dist/src/s3/wire.d.ts +60 -0
- package/dist/src/s3/wire.js +157 -0
- package/dist/src/scenario.d.ts +3 -0
- package/dist/src/scenario.js +2 -0
- package/dist/src/schema-sample.d.ts +1 -0
- package/dist/src/schema-sample.js +21 -0
- package/dist/src/sealed-box.d.ts +14 -0
- package/dist/src/sealed-box.js +225 -0
- package/dist/src/serve-http.d.ts +14 -0
- package/dist/src/serve-http.js +27 -3
- package/dist/src/serve.d.ts +6 -0
- package/dist/src/serve.js +69 -14
- package/dist/src/signing.d.ts +135 -0
- package/dist/src/signing.js +222 -0
- package/dist/src/sigv4.d.ts +48 -0
- package/dist/src/sigv4.js +167 -0
- package/dist/src/smtp.d.ts +16 -0
- package/dist/src/smtp.js +72 -0
- package/dist/src/sockets.d.ts +51 -0
- package/dist/src/sockets.js +90 -0
- package/dist/src/state-system.d.ts +1 -0
- package/dist/src/state-system.js +1 -1
- package/dist/src/storage.d.ts +1 -1
- package/dist/src/storage.js +3 -3
- package/dist/src/trace-context.js +1 -1
- package/dist/src/twin-fetch.d.ts +0 -7
- package/dist/src/twin-fetch.js +0 -14
- package/dist/src/vendor-call.d.ts +6 -0
- package/dist/src/vendor-call.js +41 -0
- package/dist/src/world-store.js +1 -1
- package/dist/vendor-hosts.cjs +36 -125
- package/dist/vendor-hosts.d.cts +8 -0
- package/generated/pack-facts.json +1410 -3069
- package/inject.cjs +64 -9
- package/pack-facts.cjs +44 -0
- package/package.json +17 -3
- package/src/actions.ts +23 -16
- package/src/ancestry.ts +74 -2
- package/src/anthropic-wire.ts +137 -0
- package/src/bytes.ts +42 -0
- package/src/changeset.ts +5 -5
- package/src/clickhouse/index.ts +6 -0
- package/src/clickhouse/sql.ts +3059 -0
- package/src/clickhouse/types.ts +44 -0
- package/src/clickhouse/values.ts +697 -0
- package/src/client-bundle.ts +2 -3
- package/src/cors.ts +34 -0
- package/src/derived-core.ts +1013 -146
- package/src/derived-real.ts +434 -0
- package/src/derived.ts +73 -3
- package/src/emit.ts +1 -2
- package/src/events.ts +449 -0
- package/src/executor.ts +24 -2
- package/src/file-response.ts +27 -0
- package/src/fork.ts +3 -2
- package/src/git/history.ts +19 -0
- package/src/git/index.ts +1 -0
- package/src/git/lfs.ts +67 -0
- package/src/git/objects.ts +3 -5
- package/src/git/smart-http.ts +56 -6
- package/src/graphql-wire.ts +106 -0
- package/src/grpc-wire.ts +159 -0
- package/src/h2.ts +627 -0
- package/src/head.ts +132 -41
- package/src/history.ts +6 -6
- package/src/hpack.json +1 -0
- package/src/index.ts +82 -329
- package/src/log.ts +27 -18
- package/src/machines.ts +151 -0
- package/src/managed-database.ts +299 -0
- package/src/multipart.ts +51 -0
- package/src/observe.ts +31 -15
- package/src/openai-wire.ts +371 -0
- package/src/pack-assets.ts +15 -11
- package/src/pack-fetch.ts +458 -0
- package/src/pack-paths.ts +72 -0
- package/src/packRegistry.ts +79 -167
- package/src/people.ts +31 -0
- package/src/placeholder-image.ts +88 -0
- package/src/protobuf.ts +251 -0
- package/src/redis/engine.ts +1 -1
- package/src/request-scope.ts +8 -4
- package/src/resource-blob.ts +13 -0
- package/src/runtime.ts +344 -0
- package/src/s3/wire.ts +172 -0
- package/src/scenario.ts +4 -0
- package/src/schema-sample.ts +24 -0
- package/src/sealed-box.ts +182 -0
- package/src/serve-http.ts +31 -3
- package/src/serve.ts +58 -14
- package/src/signing.ts +231 -0
- package/src/sigv4.ts +158 -0
- package/src/smtp.ts +76 -0
- package/src/sockets.ts +140 -0
- package/src/state-system.ts +2 -2
- package/src/storage.ts +3 -3
- package/src/trace-context.ts +1 -1
- package/src/twin-fetch.ts +0 -20
- package/src/vendor-call.ts +41 -0
- package/src/world-store.ts +1 -1
- package/vendor-hosts.cjs +36 -125
- package/vendor-hosts.d.cts +8 -0
- package/dist/src/mirror-shell.d.ts +0 -2
- package/dist/src/mirror-shell.js +0 -13
- package/dist/src/v1-removed.d.ts +0 -159
- package/dist/src/v1-removed.js +0 -124
- package/src/mirror-shell.ts +0 -15
- package/src/v1-removed.ts +0 -172
package/src/signing.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// SIGNING ON THE CONTEXT (docs/contributing/architecture.md, "What an author writes, and how": the tokens-and-keys row)
|
|
2
|
+
// — one deterministic implementation of what vendors sign with, given to handlers as `ctx.crypto`, so no pack reaches
|
|
3
|
+
// `node:crypto` itself: JWTs (RS256 with a pack's instance key, HS256 with a shared secret), their verification, a
|
|
4
|
+
// JWKS for a public key, an HMAC and a SHA-256. Nothing here is random: a key is the pack's data (a PEM it declares),
|
|
5
|
+
// and every value a signature carries comes from the call.
|
|
6
|
+
// A namespace, never named imports: a browser client bundles the kernel, and the browser's `node:crypto` polyfill has no
|
|
7
|
+
// createPublicKey or timingSafeEqual; named, the bundle fails to build. They are reached only when a signature is made.
|
|
8
|
+
import * as nodeCrypto from 'node:crypto';
|
|
9
|
+
import { sealedBoxKeyPair, sealedBoxOpen } from './sealed-box.ts';
|
|
10
|
+
|
|
11
|
+
type Row = Record<string, unknown>;
|
|
12
|
+
export type Jwk = { kty: 'RSA'; use: 'sig'; alg: 'RS256'; kid: string; n: string; e: string };
|
|
13
|
+
export type Jwks = { keys: Jwk[] };
|
|
14
|
+
/** A signing key: an RSA private key as a PEM (its public half, given or derived, is what a JWKS serves and names by
|
|
15
|
+
* `kid`), or a shared secret. Only deterministic algorithms: an ECDSA signature is random, so a World signs none. */
|
|
16
|
+
export type SigningKey = { alg: 'RS256' | 'RS384' | 'RS512'; privatePem: string; publicPem?: string } | { alg: 'HS256' | 'HS384' | 'HS512'; secret: string };
|
|
17
|
+
/** The algorithms `jwtSign` signs with. */
|
|
18
|
+
export const SIGNING_ALGORITHMS: ReadonlyArray<SigningKey['alg']> = ['RS256', 'RS384', 'RS512', 'HS256', 'HS384', 'HS512'];
|
|
19
|
+
const digestOf = (alg: string): string => `sha${alg.slice(2)}`;
|
|
20
|
+
/** Whether a token holds: its signature and its `exp`/`nbf` at `now` (seconds), with the payload when it does. */
|
|
21
|
+
export type JwtVerdict = { valid: boolean; payload?: Row; reason?: 'malformed' | 'unexpected_alg' | 'no_matching_key' | 'bad_signature' | 'expired' | 'not_yet_valid' };
|
|
22
|
+
|
|
23
|
+
const base64url = (input: Buffer | string): string => Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
24
|
+
const fromBase64url = (s: string): Buffer => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
25
|
+
const jsonPart = (value: unknown): string => base64url(JSON.stringify(value));
|
|
26
|
+
|
|
27
|
+
/** A public key's id: a prefix and the first 16 hex of SHA-256 over its DER (what its JWKS and every token name). */
|
|
28
|
+
export function keyId(publicPem: string, prefix = 'ins_'): string {
|
|
29
|
+
const der = nodeCrypto.createPublicKey(publicPem).export({ type: 'spki', format: 'der' });
|
|
30
|
+
return `${prefix}${nodeCrypto.createHash('sha256').update(der).digest('hex').slice(0, 16)}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A JWT: `iat` and `nbf` are `now` (seconds, the World's), `exp` `now` plus the lifetime (60 s when none is given),
|
|
34
|
+
* unless the claims name their own. RS256 tokens carry the key's `kid`. */
|
|
35
|
+
export function jwtSign(claims: Row, key: SigningKey, opts: { now: number; expiresInSeconds?: number; kidPrefix?: string }): string {
|
|
36
|
+
const header = 'privatePem' in key
|
|
37
|
+
? { alg: key.alg, typ: 'JWT', kid: keyId(key.publicPem ?? nodeCrypto.createPublicKey(key.privatePem).export({ type: 'spki', format: 'pem' }).toString(), opts.kidPrefix) }
|
|
38
|
+
: { alg: key.alg, typ: 'JWT' };
|
|
39
|
+
const payload = { iat: opts.now, exp: opts.now + (opts.expiresInSeconds ?? 60), nbf: opts.now, ...claims };
|
|
40
|
+
const input = `${jsonPart(header)}.${jsonPart(payload)}`;
|
|
41
|
+
if ('secret' in key) return `${input}.${base64url(nodeCrypto.createHmac(digestOf(key.alg), key.secret).update(input).digest())}`;
|
|
42
|
+
const signer = nodeCrypto.createSign(`RSA-SHA${key.alg.slice(2)}`);
|
|
43
|
+
signer.update(input);
|
|
44
|
+
signer.end();
|
|
45
|
+
return `${input}.${base64url(signer.sign(key.privatePem))}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A JWT's header and payload, unverified. */
|
|
49
|
+
export function jwtDecode(token: string): { header: Row; payload: Row } {
|
|
50
|
+
const parts = token.split('.');
|
|
51
|
+
if (parts.length !== 3) throw new Error('malformed jwt');
|
|
52
|
+
const part = (s: string): Row => JSON.parse(fromBase64url(s).toString('utf8')) as Row;
|
|
53
|
+
return { header: part(parts[0]!), payload: part(parts[1]!) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function timely(payload: Row, now: number): JwtVerdict {
|
|
57
|
+
if (typeof payload.exp === 'number' && now >= payload.exp) return { valid: false, reason: 'expired' };
|
|
58
|
+
if (typeof payload.nbf === 'number' && now < payload.nbf) return { valid: false, reason: 'not_yet_valid' };
|
|
59
|
+
return { valid: true, payload };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A token checked: an RS256 one against a JWKS (its `kid`'s key), an HS256 one against its secret (constant time),
|
|
63
|
+
* then its `exp`/`nbf` at `now` (seconds). */
|
|
64
|
+
export function jwtVerify(token: string, key: Jwks | { alg: 'HS256' | 'HS384' | 'HS512'; secret: string }, opts: { now: number }): JwtVerdict {
|
|
65
|
+
let decoded: { header: Row; payload: Row };
|
|
66
|
+
try { decoded = jwtDecode(token); } catch { return { valid: false, reason: 'malformed' }; }
|
|
67
|
+
const [h, p, s] = token.split('.') as [string, string, string];
|
|
68
|
+
if ('secret' in key) {
|
|
69
|
+
if (decoded.header.alg !== key.alg) return { valid: false, reason: 'unexpected_alg' };
|
|
70
|
+
const expected = Buffer.from(base64url(nodeCrypto.createHmac(digestOf(key.alg), key.secret).update(`${h}.${p}`).digest()), 'utf8');
|
|
71
|
+
const given = Buffer.from(s, 'utf8');
|
|
72
|
+
if (given.length !== expected.length || !nodeCrypto.timingSafeEqual(given, expected)) return { valid: false, reason: 'bad_signature' };
|
|
73
|
+
return timely(decoded.payload, opts.now);
|
|
74
|
+
}
|
|
75
|
+
if (!['RS256', 'RS384', 'RS512'].includes(String(decoded.header.alg))) return { valid: false, reason: 'unexpected_alg' };
|
|
76
|
+
// a token naming a key the set does not hold matches none; one naming no key is tried against the set's only key
|
|
77
|
+
const jwk = decoded.header.kid !== undefined ? key.keys.find((k) => k.kid === decoded.header.kid) : key.keys.length === 1 ? key.keys[0] : undefined;
|
|
78
|
+
if (!jwk) return { valid: false, reason: 'no_matching_key' };
|
|
79
|
+
const verifier = nodeCrypto.createVerify(`RSA-SHA${String(decoded.header.alg).slice(2)}`);
|
|
80
|
+
verifier.update(`${h}.${p}`);
|
|
81
|
+
verifier.end();
|
|
82
|
+
const publicKey = nodeCrypto.createPublicKey({ key: { kty: 'RSA', n: jwk.n, e: jwk.e }, format: 'jwk' });
|
|
83
|
+
if (!verifier.verify(publicKey, fromBase64url(s))) return { valid: false, reason: 'bad_signature' };
|
|
84
|
+
return timely(decoded.payload, opts.now);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The JWKS that verifies every token a public key's pair signs. */
|
|
88
|
+
export function jwks(publicPem: string, kidPrefix?: string): Jwks {
|
|
89
|
+
const jwk = nodeCrypto.createPublicKey(publicPem).export({ format: 'jwk' }) as { n?: string; e?: string };
|
|
90
|
+
return { keys: [{ kty: 'RSA', use: 'sig', alg: 'RS256', kid: keyId(publicPem, kidPrefix), n: String(jwk.n), e: String(jwk.e) }] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** An HMAC of a value, hex or base64 (a webhook's signature, a signed id): SHA-256 unless the vendor names another
|
|
94
|
+
* (OAuth 1.0a's HMAC-SHA1). The secret is its text, or its bytes where the vendor keys with a decoded secret (Svix's
|
|
95
|
+
* `whsec_` base64). */
|
|
96
|
+
export const hmac = (secret: string | Uint8Array, value: string | Uint8Array, encoding: 'hex' | 'base64' | 'base64url' = 'hex', algorithm: 'sha1' | 'sha256' | 'sha512' = 'sha256'): string => {
|
|
97
|
+
const bytes = nodeCrypto.createHmac(algorithm, secret).update(value).digest();
|
|
98
|
+
return encoding === 'base64url' ? base64url(bytes) : bytes.toString(encoding);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** A SHA-256, hex (a key stored by its hash, a deterministic id). */
|
|
102
|
+
export const sha256 = (value: string): string => nodeCrypto.createHash('sha256').update(value).digest('hex');
|
|
103
|
+
|
|
104
|
+
/** A digest of a value in the algorithm and encoding a vendor names (GoTrue's SHA-224 token hashes, a PKCE challenge's
|
|
105
|
+
* base64url SHA-256). */
|
|
106
|
+
export function digest(algorithm: 'md5' | 'sha1' | 'sha224' | 'sha256' | 'sha384' | 'sha512', value: string | Uint8Array, encoding: 'hex' | 'base64' | 'base64url' = 'hex'): string {
|
|
107
|
+
const bytes = nodeCrypto.createHash(algorithm).update(value).digest();
|
|
108
|
+
return encoding === 'base64url' ? base64url(bytes) : bytes.toString(encoding);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── OAuth 1.0a (RFC 5849): the signature a request carries, which the vendor checks against the secrets it holds ──────
|
|
112
|
+
|
|
113
|
+
/** RFC 3986 percent-encoding as RFC 5849 §3.6 uses it (unreserved: A-Z a-z 0-9 - . _ ~). */
|
|
114
|
+
export const oauth1Encode = (s: string): string =>
|
|
115
|
+
encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
116
|
+
|
|
117
|
+
/** The `Authorization: OAuth k="v", …` header's parameters, decoded, in order; undefined when it is not one. */
|
|
118
|
+
export function oauth1Header(value: string | null | undefined): Array<[string, string]> | undefined {
|
|
119
|
+
const m = value ? /^OAuth\s+(.*)$/is.exec(value.trim()) : null;
|
|
120
|
+
if (!m) return undefined;
|
|
121
|
+
const out: Array<[string, string]> = [];
|
|
122
|
+
for (const part of m[1]!.split(',')) {
|
|
123
|
+
const kv = /^\s*([^=\s]+)\s*=\s*"([^"]*)"\s*$/.exec(part);
|
|
124
|
+
if (!kv) { if (part.trim() === '') continue; return undefined; }
|
|
125
|
+
try {
|
|
126
|
+
out.push([decodeURIComponent(kv[1]!), decodeURIComponent(kv[2]!)]);
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The signature base string (RFC 5849 §3.4.1): the method, the base URL (no query) and the parameters signed, each
|
|
135
|
+
* percent-encoded and sorted by name and value. */
|
|
136
|
+
export function oauth1BaseString(method: string, baseUrl: string, pairs: ReadonlyArray<readonly [string, string]>): string {
|
|
137
|
+
const encoded = pairs.map(([k, v]) => [oauth1Encode(k), oauth1Encode(v)] as const).sort((a, b) => (a[0] === b[0] ? (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0) : a[0] < b[0] ? -1 : 1));
|
|
138
|
+
return `${method.toUpperCase()}&${oauth1Encode(baseUrl)}&${oauth1Encode(encoded.map(([k, v]) => `${k}=${v}`).join('&'))}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The HMAC-SHA1 signature (RFC 5849 §3.4.2) over a base string, keyed by the consumer secret and the token secret. */
|
|
142
|
+
export const oauth1Signature = (base: string, consumerSecret: string, tokenSecret = ''): string =>
|
|
143
|
+
hmac(`${oauth1Encode(consumerSecret)}&${oauth1Encode(tokenSecret)}`, base, 'base64', 'sha1');
|
|
144
|
+
|
|
145
|
+
/** A digest of a value as its bytes (a key or an id drawn from a hash's bytes, a deterministic embedding). */
|
|
146
|
+
export function digestBytes(algorithm: 'md5' | 'sha1' | 'sha224' | 'sha256' | 'sha384' | 'sha512', value: string | Uint8Array): Uint8Array {
|
|
147
|
+
return new Uint8Array(nodeCrypto.createHash(algorithm).update(value).digest());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** An MD5, hex (an object's ETag, as S3 and storage APIs give it); never a secret's protection. */
|
|
151
|
+
export const md5 = (value: string | Uint8Array): string => nodeCrypto.createHash('md5').update(value).digest('hex');
|
|
152
|
+
|
|
153
|
+
/** A version-4-shaped UUID derived from `seed`: the same seed gives the same id every run (R9). */
|
|
154
|
+
export function uuidFrom(seed: string): string {
|
|
155
|
+
const h = sha256(seed);
|
|
156
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${'89ab'[Number.parseInt(h[16]!, 16) % 4]}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** `count` lowercase letters derived from `seed` (a 20-letter project ref, a slug's suffix), the same every run. */
|
|
160
|
+
export function lettersFrom(seed: string, count = 20): string {
|
|
161
|
+
let out = '';
|
|
162
|
+
for (let round = 0; out.length < count; round += 1) {
|
|
163
|
+
for (const byte of sha256(`${seed}:${round}`).match(/../g)!) {
|
|
164
|
+
if (out.length === count) break;
|
|
165
|
+
out += String.fromCharCode(97 + (Number.parseInt(byte, 16) % 26));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Two strings compared in constant time. */
|
|
172
|
+
export function equalSecrets(a: string, b: string): boolean {
|
|
173
|
+
const left = Buffer.from(a, 'utf8');
|
|
174
|
+
const right = Buffer.from(b, 'utf8');
|
|
175
|
+
return left.length === right.length && nodeCrypto.timingSafeEqual(left, right);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A key's type, from its PEM: the schemes the kernel signs with are the deterministic ones. */
|
|
179
|
+
function signingKey(privatePem: string): nodeCrypto.KeyObject {
|
|
180
|
+
const key = nodeCrypto.createPrivateKey(privatePem);
|
|
181
|
+
// ECDSA draws a random nonce for every signature, so the same write signs differently on every run: a World signs
|
|
182
|
+
// with RSA (PKCS #1 v1.5) or Ed25519, whose signatures are functions of the key and the bytes
|
|
183
|
+
if (key.asymmetricKeyType !== 'rsa' && key.asymmetricKeyType !== 'ed25519') throw new Error(`signWith: a ${key.asymmetricKeyType} key signs randomly; a World signs with an RSA or Ed25519 key`);
|
|
184
|
+
return key;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Bytes signed with a private key (RSA with SHA-256, or Ed25519), in the encoding asked: a certificate's signature,
|
|
188
|
+
* a transparency log's checkpoint. */
|
|
189
|
+
export function signWith(privatePem: string, data: string | Uint8Array, encoding: 'base64' | 'hex' | 'base64url' = 'base64'): string {
|
|
190
|
+
const key = signingKey(privatePem);
|
|
191
|
+
const bytes = nodeCrypto.sign(key.asymmetricKeyType === 'ed25519' ? null : 'sha256', typeof data === 'string' ? Buffer.from(data) : data, key);
|
|
192
|
+
return encoding === 'base64url' ? base64url(bytes) : bytes.toString(encoding);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Whether a signature holds over bytes under a public key or a certificate's (PEM, or a certificate's DER): any
|
|
196
|
+
* scheme a vendor uses (ECDSA, RSA, Ed25519), since verifying draws nothing. The digest is SHA-256 unless named. */
|
|
197
|
+
export function verifyWith(publicKey: string | Uint8Array, data: string | Uint8Array, signature: string | Uint8Array, opts: { digest?: 'sha256' | 'sha384' | 'sha512'; encoding?: 'base64' | 'hex' } = {}): boolean {
|
|
198
|
+
try {
|
|
199
|
+
const key = typeof publicKey !== 'string' || publicKey.includes('CERTIFICATE') ? new nodeCrypto.X509Certificate(typeof publicKey === 'string' ? publicKey : Buffer.from(publicKey)).publicKey : nodeCrypto.createPublicKey(publicKey);
|
|
200
|
+
const sig = typeof signature === 'string' ? Buffer.from(signature, opts.encoding ?? 'base64') : signature;
|
|
201
|
+
return nodeCrypto.verify(key.asymmetricKeyType === 'ed25519' ? null : (opts.digest ?? 'sha256'), typeof data === 'string' ? Buffer.from(data) : data, key, sig);
|
|
202
|
+
} catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A public key's PEM and its DER (SubjectPublicKeyInfo), from a public or private key's PEM or a certificate. */
|
|
208
|
+
export function publicKeyOf(key: string): { pem: string; der: Uint8Array } {
|
|
209
|
+
const k = key.includes('CERTIFICATE') ? new nodeCrypto.X509Certificate(key).publicKey : nodeCrypto.createPublicKey(key);
|
|
210
|
+
return { pem: String(k.export({ type: 'spki', format: 'pem' })), der: new Uint8Array(k.export({ type: 'spki', format: 'der' })) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** An X.509 certificate read (PEM or DER): its bytes, names, validity and key, and whether an issuer's key signed it;
|
|
214
|
+
* undefined when it is not one. */
|
|
215
|
+
export function certificateOf(cert: string | Uint8Array): { der: Uint8Array; subject: string; issuer: string; subjectAltName: string | undefined; notBefore: string; notAfter: string; publicKey: { pem: string; der: Uint8Array }; issuedBy(issuer: string | Uint8Array): boolean } | undefined {
|
|
216
|
+
try {
|
|
217
|
+
const c = new nodeCrypto.X509Certificate(typeof cert === 'string' ? cert : Buffer.from(cert));
|
|
218
|
+
return {
|
|
219
|
+
der: new Uint8Array(c.raw), subject: c.subject, issuer: c.issuer, subjectAltName: c.subjectAltName,
|
|
220
|
+
notBefore: new Date(c.validFrom).toISOString(), notAfter: new Date(c.validTo).toISOString(),
|
|
221
|
+
publicKey: publicKeyOf(c.toString()),
|
|
222
|
+
issuedBy: (issuer) => { try { return c.verify(new nodeCrypto.X509Certificate(typeof issuer === 'string' ? issuer : Buffer.from(issuer)).publicKey); } catch { return false; } },
|
|
223
|
+
};
|
|
224
|
+
} catch {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** What a handler signs and hashes with: `ctx.crypto`. */
|
|
230
|
+
export const handlerCrypto = { jwtSign, jwtVerify, jwtDecode, jwks, keyId, hmac, sha256, md5, digest, uuidFrom, lettersFrom, equalSecrets, sealedBoxKeyPair, sealedBoxOpen, signWith, verifyWith, publicKeyOf, certificateOf } as const;
|
|
231
|
+
export type HandlerCrypto = typeof handlerCrypto;
|
package/src/sigv4.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// AWS Signature Version 4, VERIFIED: what an AWS-shaped twin (S3, Secrets Manager, R2's S3 endpoint) checks before it
|
|
2
|
+
// answers a signed request. The published algorithm (https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv.html,
|
|
3
|
+
// "Create a signed AWS API request"), the same one executor.ts signs with, run from the other side: the request names
|
|
4
|
+
// its access key, its credential scope and the headers it signed; the twin finds the key's secret, builds the canonical
|
|
5
|
+
// request from what arrived, and compares signatures. A presigned URL carries the same parts in its query
|
|
6
|
+
// (X-Amz-Credential, X-Amz-SignedHeaders, X-Amz-Signature; its payload `UNSIGNED-PAYLOAD`).
|
|
7
|
+
//
|
|
8
|
+
// Where the request reached the twin rather than the vendor, the host it signed is the vendor's: the World's injector
|
|
9
|
+
// names it (`x-volter-twin-original-host`), else the Host the request carries, else its URL's. S3 signs its path as
|
|
10
|
+
// sent and every other service encodes it a second time (the specification's own clause, as in executor.ts). A
|
|
11
|
+
// streaming upload (`STREAMING-AWS4-HMAC-SHA256-PAYLOAD`) is checked by its seed signature; its chunk signatures are
|
|
12
|
+
// not (the twin's reading: the seed binds the request, and the chunks are the body the twin keeps).
|
|
13
|
+
import * as nodeCrypto from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
export type SigV4Verdict =
|
|
16
|
+
| { ok: true; keyId: string; region: string; service: string }
|
|
17
|
+
| { ok: false; reason: 'unsigned' | 'malformed' | 'unknown_key' | 'bad_signature' | 'expired'; keyId?: string };
|
|
18
|
+
|
|
19
|
+
/** The path a request arrived with, where the kernel routed it by another (a virtual-hosted bucket put in front of the
|
|
20
|
+
* path: pack-fetch's `routed`); a client signed the path it sent. */
|
|
21
|
+
export const ORIGINAL_PATH_HEADER = 'x-volter-original-path';
|
|
22
|
+
|
|
23
|
+
const rfc3986 = (v: string): string => encodeURIComponent(v).replace(/[!\x27()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
24
|
+
const sha256Hex = (data: string | Uint8Array): string => nodeCrypto.createHash('sha256').update(data).digest('hex');
|
|
25
|
+
const hmac = (key: Uint8Array | string, data: string): Buffer => nodeCrypto.createHmac('sha256', key).update(data).digest();
|
|
26
|
+
|
|
27
|
+
/** The time a SigV4 date names (`20260105T093000Z`), in epoch milliseconds. */
|
|
28
|
+
const dateOf = (amz: string): number => Date.parse(`${amz.slice(0, 4)}-${amz.slice(4, 6)}-${amz.slice(6, 8)}T${amz.slice(9, 11)}:${amz.slice(11, 13)}:${amz.slice(13, 15)}Z`);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether `request` is signed with SigV4 by a key `secretOf` knows (its secret, or undefined for a key the twin never
|
|
32
|
+
* issued). `body` is the bytes it carries (the payload hash of
|
|
33
|
+
* a service that sends no `x-amz-content-sha256`); `now` is the World's instant, in epoch milliseconds, for a presigned
|
|
34
|
+
* URL's expiry. The caller decides what an unsigned request is (a public read, or a refusal).
|
|
35
|
+
*/
|
|
36
|
+
export async function verifySigV4(request: Request, body: Uint8Array | string | undefined, secretOf: (keyId: string) => string | undefined | Promise<string | undefined>, now: number): Promise<SigV4Verdict> {
|
|
37
|
+
const url = new URL(request.url);
|
|
38
|
+
const header = request.headers.get('authorization') ?? '';
|
|
39
|
+
const presigned = url.searchParams.get('X-Amz-Algorithm') === 'AWS4-HMAC-SHA256';
|
|
40
|
+
let credential: string; let signedHeaders: string; let signature: string; let amzDate: string; let payloadHash: string;
|
|
41
|
+
if (/^AWS4-HMAC-SHA256\s/i.test(header)) {
|
|
42
|
+
const parts = Object.fromEntries(header.replace(/^AWS4-HMAC-SHA256\s+/i, '').split(/,\s*/).map((p) => { const at = p.indexOf('='); return [p.slice(0, at).trim(), p.slice(at + 1).trim()]; }));
|
|
43
|
+
if (!parts.Credential || !parts.SignedHeaders || !parts.Signature) return { ok: false, reason: 'malformed' };
|
|
44
|
+
credential = parts.Credential; signedHeaders = parts.SignedHeaders; signature = parts.Signature;
|
|
45
|
+
amzDate = request.headers.get('x-amz-date') ?? '';
|
|
46
|
+
const declared = request.headers.get('x-amz-content-sha256');
|
|
47
|
+
payloadHash = declared ?? sha256Hex(body ?? '');
|
|
48
|
+
} else if (presigned) {
|
|
49
|
+
credential = url.searchParams.get('X-Amz-Credential') ?? ''; signedHeaders = url.searchParams.get('X-Amz-SignedHeaders') ?? '';
|
|
50
|
+
signature = url.searchParams.get('X-Amz-Signature') ?? ''; amzDate = url.searchParams.get('X-Amz-Date') ?? '';
|
|
51
|
+
payloadHash = 'UNSIGNED-PAYLOAD';
|
|
52
|
+
const expires = Number(url.searchParams.get('X-Amz-Expires') ?? '');
|
|
53
|
+
if (!Number.isFinite(expires) || !/^\d{8}T\d{6}Z$/.test(amzDate)) return { ok: false, reason: 'malformed' };
|
|
54
|
+
if (now > dateOf(amzDate) + expires * 1000) return { ok: false, reason: 'expired' };
|
|
55
|
+
} else {
|
|
56
|
+
return { ok: false, reason: 'unsigned' };
|
|
57
|
+
}
|
|
58
|
+
const [keyId = '', dateStamp = '', region = '', service = '', terminal = ''] = credential.split('/');
|
|
59
|
+
if (!keyId || terminal !== 'aws4_request' || !/^\d{8}$/.test(dateStamp) || !/^\d{8}T\d{6}Z$/.test(amzDate)) return { ok: false, reason: 'malformed', ...(keyId ? { keyId } : {}) };
|
|
60
|
+
const secret = await secretOf(keyId);
|
|
61
|
+
if (secret === undefined) return { ok: false, reason: 'unknown_key', keyId };
|
|
62
|
+
|
|
63
|
+
// A canonical request binds the declared hash; the declaration must also describe the bytes received.
|
|
64
|
+
// S3's UNSIGNED-PAYLOAD and streaming markers are separate wire modes, not ordinary payload digests.
|
|
65
|
+
// https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sig-v4-header-based-auth.html
|
|
66
|
+
const declaredPayload = request.headers.get('x-amz-content-sha256');
|
|
67
|
+
if (declaredPayload !== null && /^[0-9a-f]{64}$/i.test(declaredPayload)
|
|
68
|
+
&& declaredPayload.toLowerCase() !== sha256Hex(body ?? '')) {
|
|
69
|
+
return { ok: false, reason: 'bad_signature', keyId };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const host = request.headers.get('x-volter-twin-original-host') ?? request.headers.get('host') ?? url.host;
|
|
73
|
+
const sentPath = request.headers.get(ORIGINAL_PATH_HEADER) ?? url.pathname;
|
|
74
|
+
const raw = sentPath === '' ? '/' : sentPath;
|
|
75
|
+
const canonicalUri = service === 's3' ? raw : raw.split('/').map((segment) => rfc3986(decodeURIComponent(segment))).map(rfc3986).join('/');
|
|
76
|
+
const canonicalQuery = [...url.searchParams.entries()]
|
|
77
|
+
.filter(([k]) => !(presigned && k === 'X-Amz-Signature'))
|
|
78
|
+
.map(([k, v]) => [rfc3986(k), rfc3986(v)] as const)
|
|
79
|
+
.sort((left, right) => (left[0] === right[0] ? (left[1] < right[1] ? -1 : 1) : left[0] < right[0] ? -1 : 1))
|
|
80
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
81
|
+
.join('&');
|
|
82
|
+
const names = signedHeaders.toLowerCase().split(';').filter(Boolean);
|
|
83
|
+
const value = (name: string): string => (name === 'host' ? host : (request.headers.get(name) ?? '')).trim().replace(/\s+/g, ' ');
|
|
84
|
+
const canonicalHeaders = names.map((n) => `${n}:${value(n)}\n`).join('');
|
|
85
|
+
const canonicalRequest = [request.method.toUpperCase(), canonicalUri, canonicalQuery, canonicalHeaders, names.join(';'), payloadHash].join('\n');
|
|
86
|
+
const scope = `${dateStamp}/${region}/${service}/aws4_request`;
|
|
87
|
+
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n');
|
|
88
|
+
let key: Buffer = hmac(`AWS4${secret}`, dateStamp);
|
|
89
|
+
for (const part of [region, service, 'aws4_request']) key = hmac(key, part);
|
|
90
|
+
const expected = hmac(key, stringToSign).toString('hex');
|
|
91
|
+
const given = Buffer.from(signature.toLowerCase(), 'utf8');
|
|
92
|
+
const want = Buffer.from(expected, 'utf8');
|
|
93
|
+
if (given.length !== want.length || !nodeCrypto.timingSafeEqual(given, want)) return { ok: false, reason: 'bad_signature', keyId };
|
|
94
|
+
return { ok: true, keyId, region, service };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The SigV4 headers a client adds (`authorization`, `x-amz-date`, and for S3 `x-amz-content-sha256`), signed over the
|
|
99
|
+
* request as it will be sent: its method, URL, the headers it carries (host, `x-amz-*` and the content type are signed)
|
|
100
|
+
* and its body, at `at`. What a walk sends as an AWS SDK sends it; the verifier above is its other side.
|
|
101
|
+
*/
|
|
102
|
+
export function signSigV4(input: { method: string; url: string; headers?: Record<string, string>; body?: string | Uint8Array; keyId: string; secret: string; region: string; service: string; at: Date }): Record<string, string> {
|
|
103
|
+
const url = new URL(input.url);
|
|
104
|
+
const amzDate = `${input.at.toISOString().slice(0, 19).replace(/[:-]/g, '')}Z`;
|
|
105
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
106
|
+
const payloadHash = sha256Hex(input.body ?? '');
|
|
107
|
+
const raw = url.pathname === '' ? '/' : url.pathname;
|
|
108
|
+
const canonicalUri = input.service === 's3' ? raw : raw.split('/').map((segment) => rfc3986(decodeURIComponent(segment))).map(rfc3986).join('/');
|
|
109
|
+
const canonicalQuery = [...url.searchParams.entries()]
|
|
110
|
+
.map(([k, v]) => [rfc3986(k), rfc3986(v)] as const)
|
|
111
|
+
.sort((left, right) => (left[0] === right[0] ? (left[1] < right[1] ? -1 : 1) : left[0] < right[0] ? -1 : 1))
|
|
112
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
113
|
+
.join('&');
|
|
114
|
+
const added: Record<string, string> = { 'x-amz-date': amzDate, ...(input.service === 's3' ? { 'x-amz-content-sha256': payloadHash } : {}) };
|
|
115
|
+
const signable = new Map<string, string>([['host', url.host]]);
|
|
116
|
+
for (const [name, value] of Object.entries({ ...(input.headers ?? {}), ...added })) {
|
|
117
|
+
const n = name.toLowerCase();
|
|
118
|
+
if (n.startsWith('x-amz-') || n === 'content-type') signable.set(n, value.trim().replace(/\s+/g, ' '));
|
|
119
|
+
}
|
|
120
|
+
const names = [...signable.keys()].sort();
|
|
121
|
+
const canonicalRequest = [input.method.toUpperCase(), canonicalUri, canonicalQuery, names.map((n) => `${n}:${signable.get(n)!}\n`).join(''), names.join(';'), payloadHash].join('\n');
|
|
122
|
+
const scope = `${dateStamp}/${input.region}/${input.service}/aws4_request`;
|
|
123
|
+
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n');
|
|
124
|
+
let key: Buffer = hmac(`AWS4${input.secret}`, dateStamp);
|
|
125
|
+
for (const part of [input.region, input.service, 'aws4_request']) key = hmac(key, part);
|
|
126
|
+
return { ...added, authorization: `AWS4-HMAC-SHA256 Credential=${input.keyId}/${scope}, SignedHeaders=${names.join(';')}, Signature=${hmac(key, stringToSign).toString('hex')}` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A presigned URL signed again with a key: its date, expiry, region, service and signed headers are the URL's own
|
|
131
|
+
* (X-Amz-Date, X-Amz-Expires, X-Amz-Credential's scope, X-Amz-SignedHeaders, `host` when it names none), its key and
|
|
132
|
+
* signature this key's. What an SDK's getSignedUrl makes, for a walk that wrote the URL as its client printed it.
|
|
133
|
+
*/
|
|
134
|
+
export function presignSigV4(input: { method: string; url: string; keyId: string; secret: string; host?: string }): string {
|
|
135
|
+
const url = new URL(input.url);
|
|
136
|
+
const q = url.searchParams;
|
|
137
|
+
const [, dateStamp = '', region = '', service = ''] = (q.get('X-Amz-Credential') ?? '').split('/');
|
|
138
|
+
const amzDate = q.get('X-Amz-Date') ?? '';
|
|
139
|
+
q.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
|
|
140
|
+
q.set('X-Amz-Credential', `${input.keyId}/${dateStamp}/${region}/${service}/aws4_request`);
|
|
141
|
+
if (!q.has('X-Amz-SignedHeaders')) q.set('X-Amz-SignedHeaders', 'host');
|
|
142
|
+
q.delete('X-Amz-Signature');
|
|
143
|
+
const raw = url.pathname === '' ? '/' : url.pathname;
|
|
144
|
+
const canonicalUri = service === 's3' ? raw : raw.split('/').map((segment) => rfc3986(decodeURIComponent(segment))).map(rfc3986).join('/');
|
|
145
|
+
const canonicalQuery = [...q.entries()]
|
|
146
|
+
.map(([k, v]) => [rfc3986(k), rfc3986(v)] as const)
|
|
147
|
+
.sort((left, right) => (left[0] === right[0] ? (left[1] < right[1] ? -1 : 1) : left[0] < right[0] ? -1 : 1))
|
|
148
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
149
|
+
.join('&');
|
|
150
|
+
const names = (q.get('X-Amz-SignedHeaders') ?? 'host').toLowerCase().split(';').filter(Boolean);
|
|
151
|
+
const canonicalHeaders = names.map((n) => `${n}:${n === 'host' ? (input.host ?? url.host) : ''}\n`).join('');
|
|
152
|
+
const canonicalRequest = [input.method.toUpperCase(), canonicalUri, canonicalQuery, canonicalHeaders, names.join(';'), 'UNSIGNED-PAYLOAD'].join('\n');
|
|
153
|
+
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, `${dateStamp}/${region}/${service}/aws4_request`, sha256Hex(canonicalRequest)].join('\n');
|
|
154
|
+
let key: Buffer = hmac(`AWS4${input.secret}`, dateStamp);
|
|
155
|
+
for (const part of [region, service, 'aws4_request']) key = hmac(key, part);
|
|
156
|
+
q.set('X-Amz-Signature', hmac(key, stringToSign).toString('hex'));
|
|
157
|
+
return url.toString();
|
|
158
|
+
}
|
package/src/smtp.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// A vendor's mail to a person, sent over SMTP (plain, no authentication) to the host the vendor's settings name — in a
|
|
2
|
+
// World, the World's mail twin — as the vendor's own mailer sends it (GoTrue's confirmation and magic links). A handler
|
|
3
|
+
// reaches it as `ctx.mail`. The World's egress rule is applied before the socket opens: a sealed World or one with a
|
|
4
|
+
// network policy refuses a host outside it; a local World with neither lets a configured outside host be reached, as it
|
|
5
|
+
// lets the application reach one. The socket is opened only when a mail is sent (nothing at import).
|
|
6
|
+
|
|
7
|
+
/** Where a mail goes: the SMTP host and port, and the sender. */
|
|
8
|
+
export type SmtpRoute = { host: string; port: number; from: string; senderName?: string };
|
|
9
|
+
/** One mail: its recipient, subject and HTML body. */
|
|
10
|
+
export type Mail = { to: string; subject: string; html: string };
|
|
11
|
+
|
|
12
|
+
type Socket = import('node:net').Socket;
|
|
13
|
+
|
|
14
|
+
class SmtpSession {
|
|
15
|
+
private buffer = '';
|
|
16
|
+
private waiting: Array<(line: string) => void> = [];
|
|
17
|
+
constructor(private readonly socket: Socket) {
|
|
18
|
+
socket.setEncoding('utf8');
|
|
19
|
+
socket.on('data', (chunk: string) => {
|
|
20
|
+
this.buffer += chunk;
|
|
21
|
+
let end: number;
|
|
22
|
+
// a reply is complete at a line whose code is followed by a space (multi-line replies use a dash)
|
|
23
|
+
while ((end = this.buffer.search(/^\d{3} .*\r?\n/m)) >= 0) {
|
|
24
|
+
const lineEnd = this.buffer.indexOf('\n', end);
|
|
25
|
+
const reply = this.buffer.slice(0, lineEnd + 1);
|
|
26
|
+
this.buffer = this.buffer.slice(lineEnd + 1);
|
|
27
|
+
this.waiting.shift()?.(reply.trim().split(/\r?\n/).pop()!);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
reply(): Promise<string> { return new Promise((resolve) => this.waiting.push(resolve)); }
|
|
32
|
+
async command(line: string, expect: number): Promise<void> {
|
|
33
|
+
const next = this.reply();
|
|
34
|
+
this.socket.write(`${line}\r\n`);
|
|
35
|
+
const reply = await next;
|
|
36
|
+
if (!reply.startsWith(String(expect))) throw new Error(`SMTP ${line.split(' ')[0]}: ${reply}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** How long a mail may take, connect to QUIT (a host that never answers must not hold the request open). */
|
|
41
|
+
const SMTP_TIMEOUT_MS = 10_000;
|
|
42
|
+
|
|
43
|
+
/** Send one mail. Throws when it cannot be delivered (refused by the World's egress rule, unreachable, or refused by the
|
|
44
|
+
* server), so the vendor answers its own mailer failure. */
|
|
45
|
+
export async function sendMail(route: SmtpRoute, mail: Mail, headers: Record<string, string> = {}): Promise<void> {
|
|
46
|
+
const [{ connect }, { worldEgressRefusal }] = await Promise.all([
|
|
47
|
+
import('node:net'),
|
|
48
|
+
import('../network-policy.cjs') as Promise<{ worldEgressRefusal: (value: string) => string | null }>,
|
|
49
|
+
]);
|
|
50
|
+
const refusal = worldEgressRefusal(`http://${route.host}:${route.port}/`);
|
|
51
|
+
if (refusal !== null) throw new Error(refusal);
|
|
52
|
+
const socket = connect(route.port, route.host);
|
|
53
|
+
socket.setTimeout(SMTP_TIMEOUT_MS, () => socket.destroy(new Error(`SMTP ${route.host}:${route.port}: no answer in ${SMTP_TIMEOUT_MS} ms`)));
|
|
54
|
+
const session = new SmtpSession(socket);
|
|
55
|
+
const greeting = session.reply();
|
|
56
|
+
const failed = new Promise<never>((_, reject) => { socket.once('error', reject); socket.once('close', () => reject(new Error('SMTP connection closed'))); });
|
|
57
|
+
failed.catch(() => {});
|
|
58
|
+
const step = <T>(p: Promise<T>): Promise<T> => Promise.race([p, failed]);
|
|
59
|
+
await step(new Promise<void>((resolve) => { socket.once('connect', () => resolve()); }));
|
|
60
|
+
try {
|
|
61
|
+
if (!(await step(greeting)).startsWith('220')) throw new Error('SMTP: no greeting');
|
|
62
|
+
await step(session.command(`EHLO ${route.from.split('@')[1] ?? 'localhost'}`, 250));
|
|
63
|
+
await step(session.command(`MAIL FROM:<${route.from}>`, 250));
|
|
64
|
+
await step(session.command(`RCPT TO:<${mail.to}>`, 250));
|
|
65
|
+
await step(session.command('DATA', 354));
|
|
66
|
+
const from = route.senderName ? `"${route.senderName.replace(/"/g, '')}" <${route.from}>` : route.from;
|
|
67
|
+
const lines = [
|
|
68
|
+
`From: ${from}`, `To: ${mail.to}`, `Subject: ${mail.subject}`, 'MIME-Version: 1.0', 'Content-Type: text/html; charset=utf-8',
|
|
69
|
+
...Object.entries(headers).map(([k, v]) => `${k}: ${v}`), '', ...mail.html.split(/\r?\n/).map((l) => (l.startsWith('.') ? `.${l}` : l)),
|
|
70
|
+
];
|
|
71
|
+
await step(session.command(`${lines.join('\r\n')}\r\n.`, 250));
|
|
72
|
+
await step(session.command('QUIT', 221)).catch(() => {});
|
|
73
|
+
} finally {
|
|
74
|
+
socket.destroy();
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/sockets.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// A vendor's persistent wire (architecture, "Other wires: sockets"): a WebSocket its clients hold open, on which the
|
|
2
|
+
// vendor sends what happens as it happens (Discord's Gateway: a bot identifies, is told the guilds it is in, and is sent
|
|
3
|
+
// each message, member and interaction as it lands). The manifest declares the socket (`sockets`); the pack's engine
|
|
4
|
+
// (`semantics/sockets.ts`, one export per socket id) speaks its protocol: what a session is sent when it opens, what it
|
|
5
|
+
// answers each frame, and which frames a write of the World sends it. The kernel holds the open sessions, serves the
|
|
6
|
+
// upgrade through the serve seam, and offers every write to every open session of the vendor, after the write's
|
|
7
|
+
// webhooks. A session's own counters (a sequence number, a heartbeat) are the connection's, never the World's state.
|
|
8
|
+
// Nothing here runs at module scope beyond the empty registry.
|
|
9
|
+
import type { EventWrite } from './events.ts';
|
|
10
|
+
import type { ScenarioEngine } from './scenario.ts';
|
|
11
|
+
import type { WebSocketPeer, WebSocketUpgrade } from './serve-http.ts';
|
|
12
|
+
|
|
13
|
+
/** A socket the vendor serves: its path (and host, when the vendor serves it on a host of its own), as the manifest
|
|
14
|
+
* declares it. */
|
|
15
|
+
/** A vendor's socket: its id, path and host; `protocols` are the subprotocols its server selects from those a client
|
|
16
|
+
* offers in `Sec-WebSocket-Protocol` (OpenAI Realtime's `realtime`), the first offered that it lists. */
|
|
17
|
+
export type SocketDecl = { id: string; path: string; host?: string; note: string; source?: string; protocols?: ReadonlyArray<string> };
|
|
18
|
+
|
|
19
|
+
/** One open connection, as its engine sees it: its own state, and the frames it sends (an object is sent as JSON). */
|
|
20
|
+
export type SocketSession = {
|
|
21
|
+
readonly id: number;
|
|
22
|
+
readonly socket: string;
|
|
23
|
+
state: Record<string, unknown>;
|
|
24
|
+
send(frame: unknown): void;
|
|
25
|
+
close(code?: number, reason?: string): void;
|
|
26
|
+
/** A reply this session will send, by its correlation key: settles when the engine calls `fulfil(key, value)`, and
|
|
27
|
+
* rejects when `ms` pass or the session closes (a request answered by a session: the tunnel's relay). */
|
|
28
|
+
expect(key: string, ms: number): Promise<unknown>;
|
|
29
|
+
/** Settles what `expect(key)` awaits; false when nothing awaits it (a late or unknown reply). */
|
|
30
|
+
fulfil(key: string, value: unknown): boolean;
|
|
31
|
+
/** The World's scenario's decision for a turn this session asks (a model vendor's turn spoken over a socket: a voice
|
|
32
|
+
* agent's reply), the turn given as the pack's scenario adapter reads a request; undefined when the pack has no
|
|
33
|
+
* scenario or the World carries none. A fault decision is the engine's to answer in its own frames. */
|
|
34
|
+
decide(asked: unknown): Promise<ScenarioServed | undefined>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** What the World's scenario serves a turn: a handler's decision, a miss, or a fault (scenario.ts `serve`). */
|
|
38
|
+
export type ScenarioServed = Awaited<ReturnType<ScenarioEngine<unknown>['serve']>>;
|
|
39
|
+
|
|
40
|
+
/** A socket's protocol, over the handler contract's context (a read of the World as the request that opened it). */
|
|
41
|
+
export type SocketEngine<C> = {
|
|
42
|
+
open?(session: SocketSession, ctx: C): void | Promise<void>;
|
|
43
|
+
message(session: SocketSession, data: string, ctx: C): void | Promise<void>;
|
|
44
|
+
/** A write of the World: the frames it sends this session, if any (the engine decides who is told what). */
|
|
45
|
+
write?(session: SocketSession, write: EventWrite, ctx: C): void | Promise<void>;
|
|
46
|
+
/** The session ended (the client hung up, or the engine closed it): the World is read and written as at a frame, so
|
|
47
|
+
* what the end settles is written (a call's final status). */
|
|
48
|
+
close?(session: SocketSession, ctx: C): void | Promise<void>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type Open = { session: SocketSession; engine: SocketEngine<unknown>; context: () => Promise<unknown>; queue: Promise<void>; pending: Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }> };
|
|
52
|
+
const sessions = new Map<string, Set<Open>>();
|
|
53
|
+
let nextSession = 1;
|
|
54
|
+
const keyOf = (service: string, root: string | undefined): string => `${service}\0${root ?? ''}`;
|
|
55
|
+
|
|
56
|
+
/** The open sessions of one socket of the vendor in a World (a handler reaches them as `ctx.sockets(id)`). */
|
|
57
|
+
export function openSessions(service: string, root: string | undefined, socket: string): SocketSession[] {
|
|
58
|
+
return [...(sessions.get(keyOf(service, root)) ?? [])].filter((o) => o.session.socket === socket).map((o) => o.session);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Offer a write to every open session of the vendor, each in its turn after what it is already doing. Queued, never
|
|
62
|
+
* awaited: the write may be one a session's own frame made, which that session finishes before it is told. */
|
|
63
|
+
export function socketWrite(service: string, root: string | undefined, write: EventWrite): void {
|
|
64
|
+
const open = sessions.get(keyOf(service, root));
|
|
65
|
+
if (!open?.size) return;
|
|
66
|
+
for (const o of open) {
|
|
67
|
+
const { engine } = o;
|
|
68
|
+
if (!engine.write) continue;
|
|
69
|
+
o.queue = o.queue.then(async () => engine.write!(o.session, write, await o.context())).catch((error) => o.session.close(1011, String((error as Error).message ?? error).slice(0, 120)));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The upgrade the serve seam takes: a request to a declared socket's path (and host) opens a session of its engine. */
|
|
74
|
+
export function socketUpgrade<C>(
|
|
75
|
+
service: string,
|
|
76
|
+
root: string | undefined,
|
|
77
|
+
decls: ReadonlyArray<SocketDecl>,
|
|
78
|
+
engines: Record<string, SocketEngine<C>>,
|
|
79
|
+
context: (request: Request, decl: SocketDecl) => Promise<C>,
|
|
80
|
+
hostOf: (request: Request) => string,
|
|
81
|
+
pathOf: (request: Request) => string = (request) => new URL(request.url).pathname,
|
|
82
|
+
decide: (asked: unknown) => Promise<ScenarioServed | undefined> = async () => undefined,
|
|
83
|
+
): WebSocketUpgrade {
|
|
84
|
+
const declFor = (request: Request): SocketDecl | undefined => decls.find((d) => {
|
|
85
|
+
const path = pathOf(request).replace(/\/+$/, '') || '/';
|
|
86
|
+
return (d.path.replace(/\/+$/, '') || '/') === path && (!d.host || d.host.toLowerCase() === hostOf(request)) && engines[d.id] !== undefined;
|
|
87
|
+
});
|
|
88
|
+
const byPeer = new WeakMap<WebSocketPeer, Open>();
|
|
89
|
+
const key = keyOf(service, root);
|
|
90
|
+
return {
|
|
91
|
+
accepts: (request) => declFor(request) !== undefined,
|
|
92
|
+
// the subprotocol the server answers with: the first the client offers that the socket declares, or, for a socket
|
|
93
|
+
// that declares none, the first offered (what the ws library and Bun answer by themselves)
|
|
94
|
+
protocol: (request) => {
|
|
95
|
+
const offered = (request.headers.get('sec-websocket-protocol') ?? '').split(',').map((p) => p.trim()).filter(Boolean);
|
|
96
|
+
const declared = declFor(request)?.protocols;
|
|
97
|
+
return declared ? offered.find((p) => declared.includes(p)) : offered[0];
|
|
98
|
+
},
|
|
99
|
+
open: (peer, request) => {
|
|
100
|
+
const decl = declFor(request)!;
|
|
101
|
+
const engine = engines[decl.id]! as SocketEngine<unknown>;
|
|
102
|
+
const pending: Open['pending'] = new Map();
|
|
103
|
+
const session: SocketSession = {
|
|
104
|
+
id: nextSession++, socket: decl.id, state: {},
|
|
105
|
+
send: (frame) => { peer.send(typeof frame === 'string' || frame instanceof Uint8Array ? frame : JSON.stringify(frame)); },
|
|
106
|
+
close: (code, reason) => { peer.close(code, reason); },
|
|
107
|
+
expect: (key, ms) => new Promise((resolve, reject) => {
|
|
108
|
+
const timer = setTimeout(() => { pending.delete(key); reject(new Error('no reply in time')); }, ms);
|
|
109
|
+
pending.set(key, { resolve, reject, timer });
|
|
110
|
+
}),
|
|
111
|
+
fulfil: (key, value) => {
|
|
112
|
+
const p = pending.get(key);
|
|
113
|
+
if (!p) return false;
|
|
114
|
+
clearTimeout(p.timer); pending.delete(key); p.resolve(value);
|
|
115
|
+
return true;
|
|
116
|
+
},
|
|
117
|
+
decide,
|
|
118
|
+
};
|
|
119
|
+
const o: Open = { session, engine, context: () => context(request.clone(), decl), queue: Promise.resolve(), pending };
|
|
120
|
+
byPeer.set(peer, o);
|
|
121
|
+
(sessions.get(key) ?? sessions.set(key, new Set()).get(key)!).add(o);
|
|
122
|
+
if (engine.open) o.queue = o.queue.then(async () => engine.open!(session, await o.context())).catch((error) => session.close(1011, String((error as Error).message ?? error).slice(0, 120)));
|
|
123
|
+
},
|
|
124
|
+
message: (peer, data) => {
|
|
125
|
+
const o = byPeer.get(peer);
|
|
126
|
+
if (!o) return;
|
|
127
|
+
const text = typeof data === 'string' ? data : new TextDecoder().decode(data);
|
|
128
|
+
o.queue = o.queue.then(async () => o.engine.message(o.session, text, await o.context())).catch((error) => o.session.close(1011, String((error as Error).message ?? error).slice(0, 120)));
|
|
129
|
+
},
|
|
130
|
+
close: (peer) => {
|
|
131
|
+
const o = byPeer.get(peer);
|
|
132
|
+
if (!o) return;
|
|
133
|
+
sessions.get(key)?.delete(o);
|
|
134
|
+
// what awaited this session's replies is told it closed
|
|
135
|
+
for (const [k, p] of o.pending) { clearTimeout(p.timer); p.reject(new Error('the session closed')); o.pending.delete(k); }
|
|
136
|
+
// the end is written after what the session is still doing, over its own context
|
|
137
|
+
if (o.engine.close) o.queue = o.queue.then(async () => o.engine.close!(o.session, await o.context())).catch(() => {});
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/state-system.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// from the vendor (the pack's refresh adapter). Reads never egress.
|
|
10
10
|
// The binding is a file in the twin's state dir (`root.json`), written by `volter twin <vendor>
|
|
11
11
|
// root`, read here — no env var, no pack code. The adapters come from the pack's descriptor
|
|
12
|
-
// (
|
|
12
|
+
// (`stateSystem`), registered by whoever mounts the twin.
|
|
13
13
|
// CHECKS run before any entry is performed: a check that fails lands the entry as `refused` with
|
|
14
14
|
// its reason and the deploy stops there. Checks are the world's files; the runtime loads them.
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
@@ -58,7 +58,7 @@ export function clearRoot(service: string, root?: string): void {
|
|
|
58
58
|
* writes a root log by hand); `ingest` folds one signed webhook. All over the kernel executor. */
|
|
59
59
|
export type StateSystemAdapters = {
|
|
60
60
|
perform?: PerformAction;
|
|
61
|
-
refresh?: (execute: RemoteExecute, opts: { root?: string; origin?: string; credential?: string }) => Promise<unknown>;
|
|
61
|
+
refresh?: (execute: RemoteExecute, opts: { root?: string; origin?: string; credential?: string; scope?: string }) => Promise<unknown>;
|
|
62
62
|
ingest?: (request: Request, ctx: { root?: string; secret?: string }) => Response | Promise<Response>;
|
|
63
63
|
};
|
|
64
64
|
|