@q32/core 0.1.5 → 0.1.6
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/package.json +2 -1
- package/src/ai.ts +42 -0
- package/src/api.ts +100 -0
- package/src/billing.ts +46 -0
- package/src/cloudflare.ts +36 -0
- package/src/crypto.ts +56 -0
- package/src/d1.ts +86 -0
- package/src/email.ts +49 -0
- package/src/encoding.ts +17 -0
- package/src/env.ts +63 -0
- package/src/hash.ts +57 -0
- package/src/http.ts +78 -0
- package/src/ids.ts +55 -0
- package/src/index.ts +22 -0
- package/src/jobs.ts +276 -0
- package/src/mcp.ts +52 -0
- package/src/oauth.ts +44 -0
- package/src/ops-events.ts +75 -0
- package/src/pg.ts +81 -0
- package/src/r2-json.ts +32 -0
- package/src/rate-limit.ts +77 -0
- package/src/seo.ts +73 -0
- package/src/session.ts +81 -0
- package/src/testing.ts +119 -0
- package/src/time.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@q32/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Shared TypeScript primitives for Q32 Cloudflare Worker projects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -115,6 +115,7 @@
|
|
|
115
115
|
},
|
|
116
116
|
"files": [
|
|
117
117
|
"dist",
|
|
118
|
+
"src",
|
|
118
119
|
"docs",
|
|
119
120
|
"README.md",
|
|
120
121
|
"LICENSE"
|
package/src/ai.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type AiMessage = {
|
|
2
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
3
|
+
content: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type AiJsonRequest = {
|
|
7
|
+
model: string;
|
|
8
|
+
messages: AiMessage[];
|
|
9
|
+
responseName?: string;
|
|
10
|
+
temperature?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type AiJsonProvider = {
|
|
14
|
+
generateJson<T>(request: AiJsonRequest): Promise<T>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type AiUsage = {
|
|
18
|
+
inputTokens?: number;
|
|
19
|
+
outputTokens?: number;
|
|
20
|
+
totalTokens?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type AiResult<T> = {
|
|
24
|
+
value: T;
|
|
25
|
+
usage?: AiUsage;
|
|
26
|
+
providerMetadata?: Record<string, unknown>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function systemUserMessages(system: string, user: string): AiMessage[] {
|
|
30
|
+
return [
|
|
31
|
+
{ role: "system", content: system },
|
|
32
|
+
{ role: "user", content: user },
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function extractJsonObject(text: string): unknown {
|
|
37
|
+
const trimmed = text.trim();
|
|
38
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) return JSON.parse(trimmed);
|
|
39
|
+
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
40
|
+
if (fenced) return JSON.parse(fenced[1].trim());
|
|
41
|
+
throw new Error("No JSON object found in model output.");
|
|
42
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { HttpError } from "./http.js";
|
|
2
|
+
|
|
3
|
+
export type ApiMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
4
|
+
|
|
5
|
+
export interface SchemaLike<T = unknown> {
|
|
6
|
+
parse(value: unknown): T;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ApiOperationSpec<TInput = unknown> = {
|
|
10
|
+
name: string;
|
|
11
|
+
title: string;
|
|
12
|
+
description: string;
|
|
13
|
+
method: ApiMethod;
|
|
14
|
+
path: string;
|
|
15
|
+
scope?: string;
|
|
16
|
+
inputSchema?: SchemaLike<TInput>;
|
|
17
|
+
openapi?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ApiOperation<TContext, TInput = unknown, TOutput = unknown> = ApiOperationSpec<TInput> & {
|
|
21
|
+
handler: (context: TContext, input: TInput) => Promise<TOutput> | TOutput;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ApiOperationRegistry<TContext> = Record<string, ApiOperation<TContext, unknown, unknown>>;
|
|
25
|
+
|
|
26
|
+
export function defineApiOperation<TContext, TInput = unknown, TOutput = unknown>(
|
|
27
|
+
operation: ApiOperation<TContext, TInput, TOutput>,
|
|
28
|
+
): ApiOperation<TContext, TInput, TOutput> {
|
|
29
|
+
return operation;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function defineApiRegistry<TContext, TRegistry extends ApiOperationRegistry<TContext>>(
|
|
33
|
+
registry: TRegistry,
|
|
34
|
+
): TRegistry {
|
|
35
|
+
const names = new Set<string>();
|
|
36
|
+
for (const [key, operation] of Object.entries(registry)) {
|
|
37
|
+
if (operation.name !== key) throw new Error(`API operation key/name mismatch: ${key} != ${operation.name}`);
|
|
38
|
+
if (names.has(operation.name)) throw new Error(`Duplicate API operation name: ${operation.name}`);
|
|
39
|
+
names.add(operation.name);
|
|
40
|
+
}
|
|
41
|
+
return registry;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function dispatchApiOperation<TContext>(
|
|
45
|
+
registry: ApiOperationRegistry<TContext>,
|
|
46
|
+
name: string,
|
|
47
|
+
context: TContext,
|
|
48
|
+
input: unknown,
|
|
49
|
+
): Promise<unknown> {
|
|
50
|
+
const operation = registry[name];
|
|
51
|
+
if (!operation) throw new HttpError(404, `Unknown API operation: ${name}`, "unknown_operation");
|
|
52
|
+
const parsed = operation.inputSchema ? operation.inputSchema.parse(input) : input;
|
|
53
|
+
return operation.handler(context, parsed);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function operationPathParameters(path: string): string[] {
|
|
57
|
+
const params = new Set<string>();
|
|
58
|
+
for (const match of path.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) params.add(match[1]);
|
|
59
|
+
return [...params];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function interpolateOperationPath(path: string, input: Record<string, unknown>): string {
|
|
63
|
+
return path.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key: string) => {
|
|
64
|
+
const value = input[key];
|
|
65
|
+
if (value === undefined || value === null || value === "") {
|
|
66
|
+
throw new HttpError(400, `Missing path parameter: ${key}`, "missing_path_parameter");
|
|
67
|
+
}
|
|
68
|
+
return encodeURIComponent(String(value));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function openApiPathsForRegistry<TContext>(
|
|
73
|
+
registry: ApiOperationRegistry<TContext>,
|
|
74
|
+
): Record<string, Record<string, Record<string, unknown>>> {
|
|
75
|
+
const paths: Record<string, Record<string, Record<string, unknown>>> = {};
|
|
76
|
+
for (const operation of Object.values(registry)) {
|
|
77
|
+
if (operation.openapi === false) continue;
|
|
78
|
+
const path = operation.path.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, "{$1}");
|
|
79
|
+
const method = operation.method.toLowerCase();
|
|
80
|
+
paths[path] ??= {};
|
|
81
|
+
paths[path][method] = {
|
|
82
|
+
operationId: operation.name,
|
|
83
|
+
summary: operation.title,
|
|
84
|
+
description: operation.description,
|
|
85
|
+
security: operation.scope ? [{ bearerAuth: [operation.scope] }] : undefined,
|
|
86
|
+
parameters: operationPathParameters(operation.path).map((name) => ({
|
|
87
|
+
name,
|
|
88
|
+
in: "path",
|
|
89
|
+
required: true,
|
|
90
|
+
schema: { type: "string" },
|
|
91
|
+
})),
|
|
92
|
+
responses: {
|
|
93
|
+
"200": {
|
|
94
|
+
description: "OK",
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return paths;
|
|
100
|
+
}
|
package/src/billing.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type BillingPlan = {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
rank: number;
|
|
5
|
+
stripePriceId?: string;
|
|
6
|
+
limits?: Record<string, number>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type SubscriptionStatus =
|
|
10
|
+
| "trialing"
|
|
11
|
+
| "active"
|
|
12
|
+
| "past_due"
|
|
13
|
+
| "canceled"
|
|
14
|
+
| "unpaid"
|
|
15
|
+
| "incomplete"
|
|
16
|
+
| "incomplete_expired"
|
|
17
|
+
| "paused";
|
|
18
|
+
|
|
19
|
+
export type BillingCustomer = {
|
|
20
|
+
customerId: string;
|
|
21
|
+
email?: string;
|
|
22
|
+
planId?: string;
|
|
23
|
+
stripeCustomerId?: string;
|
|
24
|
+
stripeSubscriptionId?: string;
|
|
25
|
+
subscriptionStatus?: SubscriptionStatus;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function planAtLeast(plans: BillingPlan[], actualPlanId: string | null | undefined, requiredPlanId: string): boolean {
|
|
29
|
+
const byId = new Map(plans.map((plan) => [plan.id, plan]));
|
|
30
|
+
const actual = actualPlanId ? byId.get(actualPlanId) : undefined;
|
|
31
|
+
const required = byId.get(requiredPlanId);
|
|
32
|
+
if (!actual || !required) return false;
|
|
33
|
+
return actual.rank >= required.rank;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function activeSubscriptionStatuses(): SubscriptionStatus[] {
|
|
37
|
+
return ["trialing", "active"];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isActiveSubscriptionStatus(status: SubscriptionStatus | null | undefined): boolean {
|
|
41
|
+
return status === "trialing" || status === "active";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function stripeEventAlreadyProcessedMessage(eventId: string): string {
|
|
45
|
+
return `Stripe event already processed: ${eventId}`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type WorkerQueueMessage<T = unknown> = {
|
|
2
|
+
jobId: string;
|
|
3
|
+
payload?: T;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type DurableObjectIdLike = {
|
|
7
|
+
toString(): string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function requireD1(env: Record<string, unknown>, binding = "DB"): D1Database {
|
|
11
|
+
const db = env[binding];
|
|
12
|
+
if (!db || typeof db !== "object" || typeof (db as D1Database).prepare !== "function") {
|
|
13
|
+
throw new Error(`Missing D1 binding: ${binding}`);
|
|
14
|
+
}
|
|
15
|
+
return db as D1Database;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function requireR2(env: Record<string, unknown>, binding: string): R2Bucket {
|
|
19
|
+
const bucket = env[binding];
|
|
20
|
+
if (!bucket || typeof bucket !== "object" || typeof (bucket as R2Bucket).put !== "function") {
|
|
21
|
+
throw new Error(`Missing R2 binding: ${binding}`);
|
|
22
|
+
}
|
|
23
|
+
return bucket as R2Bucket;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function requireQueue<T = unknown>(env: Record<string, unknown>, binding: string): Queue<T> {
|
|
27
|
+
const queue = env[binding];
|
|
28
|
+
if (!queue || typeof queue !== "object" || typeof (queue as Queue<T>).send !== "function") {
|
|
29
|
+
throw new Error(`Missing Queue binding: ${binding}`);
|
|
30
|
+
}
|
|
31
|
+
return queue as Queue<T>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isCloudflareScheduledEvent(value: unknown): value is ScheduledEvent {
|
|
35
|
+
return Boolean(value && typeof value === "object" && "scheduledTime" in value && "cron" in value);
|
|
36
|
+
}
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { fromBase64Url, randomBase64Url, toBase64Url } from "./ids.js";
|
|
2
|
+
|
|
3
|
+
const AES_GCM_IV_BYTES = 12;
|
|
4
|
+
const AES_GCM_KEY_BYTES = 32;
|
|
5
|
+
|
|
6
|
+
export type EncryptedJsonEnvelope = {
|
|
7
|
+
v: 1;
|
|
8
|
+
alg: "A256GCM";
|
|
9
|
+
iv: string;
|
|
10
|
+
ciphertext: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function createEncryptionKey(): string {
|
|
14
|
+
return randomBase64Url(AES_GCM_KEY_BYTES);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function encryptJson(value: unknown, keyMaterial: string): Promise<EncryptedJsonEnvelope> {
|
|
18
|
+
const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
|
|
19
|
+
const key = await importAesKey(keyMaterial);
|
|
20
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(value));
|
|
21
|
+
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
|
|
22
|
+
return {
|
|
23
|
+
v: 1,
|
|
24
|
+
alg: "A256GCM",
|
|
25
|
+
iv: toBase64Url(iv),
|
|
26
|
+
ciphertext: toBase64Url(new Uint8Array(ciphertext)),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function decryptJson<T>(envelope: EncryptedJsonEnvelope, keyMaterial: string): Promise<T> {
|
|
31
|
+
if (envelope.v !== 1 || envelope.alg !== "A256GCM") throw new Error("Unsupported encrypted JSON envelope.");
|
|
32
|
+
const key = await importAesKey(keyMaterial);
|
|
33
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
34
|
+
{ name: "AES-GCM", iv: toArrayBuffer(fromBase64Url(envelope.iv)) },
|
|
35
|
+
key,
|
|
36
|
+
toArrayBuffer(fromBase64Url(envelope.ciphertext)),
|
|
37
|
+
);
|
|
38
|
+
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function importAesKey(keyMaterial: string): Promise<CryptoKey> {
|
|
42
|
+
let raw: Uint8Array;
|
|
43
|
+
try {
|
|
44
|
+
raw = fromBase64Url(keyMaterial);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error("Encryption key must be 32 base64url-encoded bytes.");
|
|
47
|
+
}
|
|
48
|
+
if (raw.byteLength !== AES_GCM_KEY_BYTES) {
|
|
49
|
+
throw new Error("Encryption key must be 32 base64url-encoded bytes.");
|
|
50
|
+
}
|
|
51
|
+
return crypto.subtle.importKey("raw", toArrayBuffer(raw), "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
55
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
56
|
+
}
|
package/src/d1.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export type D1Primitive = string | number | boolean | null | Uint8Array;
|
|
2
|
+
|
|
3
|
+
export interface D1StatementResult {
|
|
4
|
+
success: boolean;
|
|
5
|
+
meta: Record<string, unknown>;
|
|
6
|
+
results?: Record<string, unknown>[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface D1PreparedStatementLike {
|
|
10
|
+
bind(...values: D1Primitive[]): D1PreparedStatementLike;
|
|
11
|
+
run(): Promise<D1StatementResult>;
|
|
12
|
+
first<T extends object = Record<string, unknown>>(): Promise<T | null>;
|
|
13
|
+
all<T extends object = Record<string, unknown>>(): Promise<{ results: T[] }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface D1DatabaseLike {
|
|
17
|
+
prepare(query: string): D1PreparedStatementLike;
|
|
18
|
+
batch(statements: D1PreparedStatementLike[]): Promise<D1StatementResult[]>;
|
|
19
|
+
exec(query: string): Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type Migration = {
|
|
23
|
+
id: string;
|
|
24
|
+
sql: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type MigrationResult = {
|
|
28
|
+
applied: string[];
|
|
29
|
+
skipped: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function ensureMigrationsTable(db: D1DatabaseLike, tableName = "schema_migrations"): Promise<void> {
|
|
33
|
+
assertSafeIdentifier(tableName);
|
|
34
|
+
await db.exec(
|
|
35
|
+
`CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
38
|
+
)`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function applyD1Migrations(
|
|
43
|
+
db: D1DatabaseLike,
|
|
44
|
+
migrations: Migration[],
|
|
45
|
+
options: { tableName?: string } = {},
|
|
46
|
+
): Promise<MigrationResult> {
|
|
47
|
+
const tableName = options.tableName ?? "schema_migrations";
|
|
48
|
+
assertSafeIdentifier(tableName);
|
|
49
|
+
await ensureMigrationsTable(db, tableName);
|
|
50
|
+
|
|
51
|
+
const applied: string[] = [];
|
|
52
|
+
const skipped: string[] = [];
|
|
53
|
+
|
|
54
|
+
for (const migration of migrations) {
|
|
55
|
+
const existing = await db.prepare(`SELECT id FROM ${tableName} WHERE id = ? LIMIT 1`).bind(migration.id).first();
|
|
56
|
+
if (existing) {
|
|
57
|
+
skipped.push(migration.id);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await db.exec(migration.sql);
|
|
62
|
+
await db.prepare(`INSERT INTO ${tableName} (id) VALUES (?)`).bind(migration.id).run();
|
|
63
|
+
applied.push(migration.id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { applied, skipped };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseJsonColumn<T>(value: string | null | undefined, fallback: T): T {
|
|
70
|
+
if (!value) return fallback;
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(value) as T;
|
|
73
|
+
} catch {
|
|
74
|
+
return fallback;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function stringifyJsonColumn(value: unknown): string {
|
|
79
|
+
return JSON.stringify(value ?? null);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function assertSafeIdentifier(value: string): void {
|
|
83
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
|
84
|
+
throw new Error(`Unsafe SQL identifier: ${value}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/email.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type EmailAddress = {
|
|
2
|
+
email: string;
|
|
3
|
+
name?: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type EmailAttachment = {
|
|
7
|
+
filename: string;
|
|
8
|
+
contentType: string;
|
|
9
|
+
data: Uint8Array | string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type SendEmailInput = {
|
|
13
|
+
from: EmailAddress;
|
|
14
|
+
to: EmailAddress[];
|
|
15
|
+
cc?: EmailAddress[];
|
|
16
|
+
bcc?: EmailAddress[];
|
|
17
|
+
replyTo?: EmailAddress[];
|
|
18
|
+
subject: string;
|
|
19
|
+
text?: string;
|
|
20
|
+
html?: string;
|
|
21
|
+
attachments?: EmailAttachment[];
|
|
22
|
+
tags?: Record<string, string>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type SendEmailResult = {
|
|
26
|
+
id: string;
|
|
27
|
+
provider?: string;
|
|
28
|
+
metadata?: Record<string, unknown>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export interface EmailProvider {
|
|
32
|
+
send(input: SendEmailInput): Promise<SendEmailResult>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatEmailAddress(address: EmailAddress): string {
|
|
36
|
+
if (!address.name) return address.email;
|
|
37
|
+
const escaped = address.name.replace(/"/g, '\\"');
|
|
38
|
+
return `"${escaped}" <${address.email}>`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function normalizeEmailAddress(email: string): string {
|
|
42
|
+
const normalized = email.trim().toLowerCase();
|
|
43
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalized)) throw new Error(`Invalid email address: ${email}`);
|
|
44
|
+
return normalized;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function appendUnsubscribeFooter(text: string, unsubscribeUrl: string): string {
|
|
48
|
+
return `${text.trim()}\n\nUnsubscribe: ${unsubscribeUrl}\n`;
|
|
49
|
+
}
|
package/src/encoding.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function base64ToArrayBuffer(value: string): ArrayBuffer {
|
|
2
|
+
const binary = atob(value);
|
|
3
|
+
const bytes = new Uint8Array(binary.length);
|
|
4
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
5
|
+
bytes[index] = binary.charCodeAt(index);
|
|
6
|
+
}
|
|
7
|
+
return bytes.buffer;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
11
|
+
const bytes = new Uint8Array(buffer);
|
|
12
|
+
let binary = "";
|
|
13
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
14
|
+
binary += String.fromCharCode(bytes[index]);
|
|
15
|
+
}
|
|
16
|
+
return btoa(binary);
|
|
17
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type EnvSource = Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
export class EnvError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "EnvError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function requiredString(env: EnvSource, key: string): string {
|
|
11
|
+
const value = env[key];
|
|
12
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
13
|
+
throw new EnvError(`Missing required env var: ${key}`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function optionalString(env: EnvSource, key: string, fallback?: string): string | undefined {
|
|
19
|
+
const value = env[key];
|
|
20
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
21
|
+
if (typeof value !== "string") throw new EnvError(`Expected ${key} to be a string.`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function requiredUrl(env: EnvSource, key: string): string {
|
|
26
|
+
const value = requiredString(env, key);
|
|
27
|
+
try {
|
|
28
|
+
return new URL(value).toString().replace(/\/$/, "");
|
|
29
|
+
} catch {
|
|
30
|
+
throw new EnvError(`Expected ${key} to be a valid URL.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function optionalBoolean(env: EnvSource, key: string, fallback = false): boolean {
|
|
35
|
+
const value = env[key];
|
|
36
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
37
|
+
if (typeof value === "boolean") return value;
|
|
38
|
+
if (typeof value !== "string") throw new EnvError(`Expected ${key} to be boolean-like.`);
|
|
39
|
+
if (/^(1|true|yes|on)$/i.test(value)) return true;
|
|
40
|
+
if (/^(0|false|no|off)$/i.test(value)) return false;
|
|
41
|
+
throw new EnvError(`Expected ${key} to be boolean-like.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function requiredBinding<T>(env: EnvSource, key: string): T {
|
|
45
|
+
const value = env[key];
|
|
46
|
+
if (value === undefined || value === null) throw new EnvError(`Missing required binding: ${key}`);
|
|
47
|
+
return value as T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type AppUrlOptions = {
|
|
51
|
+
fallback?: string;
|
|
52
|
+
keys?: string[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function appUrl(
|
|
56
|
+
env: EnvSource,
|
|
57
|
+
fallbackOrOptions: string | AppUrlOptions = "http://localhost:8787",
|
|
58
|
+
): string {
|
|
59
|
+
const options = typeof fallbackOrOptions === "string" ? { fallback: fallbackOrOptions } : fallbackOrOptions;
|
|
60
|
+
const keys = options.keys ?? ["APP_URL", "BASE_URL", "PUBLIC_APP_URL"];
|
|
61
|
+
const value = keys.map((key) => optionalString(env, key)).find((candidate) => candidate !== undefined) ?? options.fallback ?? "http://localhost:8787";
|
|
62
|
+
return value.replace(/\/$/, "");
|
|
63
|
+
}
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
|
|
3
|
+
export function toHex(bytes: Uint8Array): string {
|
|
4
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function toBase64(bytes: Uint8Array): string {
|
|
8
|
+
let binary = "";
|
|
9
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
10
|
+
return btoa(binary);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function sha256Hex(value: string | ArrayBuffer): Promise<string> {
|
|
14
|
+
const data = typeof value === "string" ? encoder.encode(value) : value;
|
|
15
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
16
|
+
return toHex(new Uint8Array(digest));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function hmacSha256Hex(
|
|
20
|
+
secret: string,
|
|
21
|
+
value: string | ArrayBuffer,
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
const signature = await hmacSha256(secret, value);
|
|
24
|
+
return toHex(new Uint8Array(signature));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function hmacSha256Base64(
|
|
28
|
+
secret: string,
|
|
29
|
+
value: string | ArrayBuffer,
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
const signature = await hmacSha256(secret, value);
|
|
32
|
+
return toBase64(new Uint8Array(signature));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function timingSafeEqual(a: string, b: string): boolean {
|
|
36
|
+
if (a.length !== b.length) return false;
|
|
37
|
+
let mismatch = 0;
|
|
38
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
39
|
+
mismatch |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
40
|
+
}
|
|
41
|
+
return mismatch === 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function hmacSha256(
|
|
45
|
+
secret: string,
|
|
46
|
+
value: string | ArrayBuffer,
|
|
47
|
+
): Promise<ArrayBuffer> {
|
|
48
|
+
const key = await crypto.subtle.importKey(
|
|
49
|
+
"raw",
|
|
50
|
+
encoder.encode(secret),
|
|
51
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
52
|
+
false,
|
|
53
|
+
["sign"],
|
|
54
|
+
);
|
|
55
|
+
const data = typeof value === "string" ? encoder.encode(value) : value;
|
|
56
|
+
return crypto.subtle.sign("HMAC", key, data);
|
|
57
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export type JsonResponseInit = ResponseInit & {
|
|
2
|
+
pretty?: boolean;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
export type FetchLike = typeof fetch;
|
|
6
|
+
|
|
7
|
+
export const defaultFetch: FetchLike = (input, init) => fetch(input, init);
|
|
8
|
+
|
|
9
|
+
export class HttpError extends Error {
|
|
10
|
+
constructor(
|
|
11
|
+
public readonly status: number,
|
|
12
|
+
message: string,
|
|
13
|
+
public readonly code = "http_error",
|
|
14
|
+
) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "HttpError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function jsonResponse(value: unknown, init: JsonResponseInit = {}): Response {
|
|
21
|
+
const headers = new Headers(init.headers);
|
|
22
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json; charset=utf-8");
|
|
23
|
+
return new Response(JSON.stringify(value, null, init.pretty ? 2 : 0), {
|
|
24
|
+
...init,
|
|
25
|
+
headers,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function errorResponse(error: unknown, fallbackStatus = 500): Response {
|
|
30
|
+
if (error instanceof HttpError) {
|
|
31
|
+
return jsonResponse({ error: { code: error.code, message: error.message } }, { status: error.status });
|
|
32
|
+
}
|
|
33
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
34
|
+
return jsonResponse({ error: { code: "internal_error", message } }, { status: fallbackStatus });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readJson<T = unknown>(request: Request): Promise<T> {
|
|
38
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
39
|
+
if (!contentType.toLowerCase().includes("application/json")) {
|
|
40
|
+
throw new HttpError(415, "Expected application/json request body.", "unsupported_media_type");
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return (await request.json()) as T;
|
|
44
|
+
} catch {
|
|
45
|
+
throw new HttpError(400, "Invalid JSON request body.", "invalid_json");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function requireBearerToken(request: Request): string {
|
|
50
|
+
const value = request.headers.get("authorization") ?? "";
|
|
51
|
+
const match = value.match(/^Bearer\s+(.+)$/i);
|
|
52
|
+
if (!match) throw new HttpError(401, "Missing bearer token.", "missing_bearer_token");
|
|
53
|
+
return match[1];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function requireAdminToken(request: Request, expected: string | undefined): void {
|
|
57
|
+
if (!expected) throw new HttpError(500, "Admin token is not configured.", "admin_token_not_configured");
|
|
58
|
+
const provided = request.headers.get("x-admin-token") ?? requireBearerToken(request);
|
|
59
|
+
if (provided !== expected) throw new HttpError(403, "Invalid admin token.", "invalid_admin_token");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function absoluteUrl(appUrl: string, path: string): string {
|
|
63
|
+
return `${appUrl.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function escapeHtml(value: string | null | undefined): string {
|
|
67
|
+
return String(value ?? "")
|
|
68
|
+
.replaceAll("&", "&")
|
|
69
|
+
.replaceAll("<", "<")
|
|
70
|
+
.replaceAll(">", ">")
|
|
71
|
+
.replaceAll('"', """)
|
|
72
|
+
.replaceAll("'", "'");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function readResponseExcerpt(response: Response, maxLength = 800): Promise<string> {
|
|
76
|
+
const text = await response.text().catch(() => "");
|
|
77
|
+
return text.slice(0, maxLength);
|
|
78
|
+
}
|