@q32/core 0.1.5 → 0.1.7

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/src/oauth.ts ADDED
@@ -0,0 +1,44 @@
1
+ export type OAuthMetadataOptions = {
2
+ issuer: string;
3
+ authorizationPath?: string;
4
+ tokenPath?: string;
5
+ registrationPath?: string;
6
+ revocationPath?: string;
7
+ scopes?: string[];
8
+ resourceDocumentation?: string;
9
+ };
10
+
11
+ export function oauthAuthorizationServerMetadata(options: OAuthMetadataOptions): Record<string, unknown> {
12
+ const issuer = options.issuer.replace(/\/$/, "");
13
+ return {
14
+ issuer,
15
+ authorization_endpoint: `${issuer}${options.authorizationPath ?? "/authorize"}`,
16
+ token_endpoint: `${issuer}${options.tokenPath ?? "/token"}`,
17
+ registration_endpoint: `${issuer}${options.registrationPath ?? "/register"}`,
18
+ revocation_endpoint: `${issuer}${options.revocationPath ?? "/revoke"}`,
19
+ response_types_supported: ["code"],
20
+ grant_types_supported: ["authorization_code", "refresh_token"],
21
+ token_endpoint_auth_methods_supported: ["client_secret_post", "none"],
22
+ code_challenge_methods_supported: ["S256"],
23
+ scopes_supported: options.scopes ?? ["mcp:read", "mcp:write"],
24
+ resource_documentation: options.resourceDocumentation ?? `${issuer}/mcp`,
25
+ };
26
+ }
27
+
28
+ export function oauthProtectedResourceMetadata(resource: string, authorizationServer: string, scopes?: string[]): Record<string, unknown> {
29
+ return {
30
+ resource,
31
+ authorization_servers: [authorizationServer.replace(/\/$/, "")],
32
+ scopes_supported: scopes ?? ["mcp:read", "mcp:write"],
33
+ bearer_methods_supported: ["header"],
34
+ };
35
+ }
36
+
37
+ export function mcpServerCard(options: { name: string; description?: string; url: string }): Record<string, unknown> {
38
+ return {
39
+ name: options.name,
40
+ description: options.description,
41
+ url: options.url,
42
+ transport: "http",
43
+ };
44
+ }
@@ -0,0 +1,75 @@
1
+ import type { D1DatabaseLike } from "./d1.js";
2
+ import { stringifyJsonColumn } from "./d1.js";
3
+
4
+ export type OpsSeverity = "debug" | "info" | "warn" | "error";
5
+
6
+ export type OpsEventInput = {
7
+ eventType: string;
8
+ severity?: OpsSeverity;
9
+ source: string;
10
+ fingerprint?: string | null;
11
+ payload?: unknown;
12
+ };
13
+
14
+ export type OpsEventRow = {
15
+ id: number;
16
+ event_type: string;
17
+ severity: OpsSeverity;
18
+ source: string;
19
+ fingerprint: string | null;
20
+ payload_json: string;
21
+ created_at: string;
22
+ };
23
+
24
+ export async function recordOpsEvent(db: D1DatabaseLike, input: OpsEventInput): Promise<void> {
25
+ await db
26
+ .prepare(
27
+ `INSERT INTO ops_events (event_type, severity, source, fingerprint, payload_json)
28
+ VALUES (?, ?, ?, ?, ?)`,
29
+ )
30
+ .bind(input.eventType, input.severity ?? "info", input.source, input.fingerprint ?? null, stringifyJsonColumn(input.payload ?? {}))
31
+ .run();
32
+ }
33
+
34
+ export async function listRecentOpsEvents(
35
+ db: D1DatabaseLike,
36
+ options: { limit?: number; eventType?: string | null } = {},
37
+ ): Promise<OpsEventRow[]> {
38
+ const limit = Math.max(1, Math.min(200, Math.floor(options.limit ?? 25)));
39
+ const eventType = options.eventType?.trim();
40
+ const statement = eventType
41
+ ? db
42
+ .prepare(
43
+ `SELECT id, event_type, severity, source, fingerprint, payload_json, created_at
44
+ FROM ops_events
45
+ WHERE event_type = ?
46
+ ORDER BY created_at DESC, id DESC
47
+ LIMIT ?`,
48
+ )
49
+ .bind(eventType, limit)
50
+ : db
51
+ .prepare(
52
+ `SELECT id, event_type, severity, source, fingerprint, payload_json, created_at
53
+ FROM ops_events
54
+ ORDER BY created_at DESC, id DESC
55
+ LIMIT ?`,
56
+ )
57
+ .bind(limit);
58
+ const rows = await statement.all<OpsEventRow>();
59
+ return rows.results ?? [];
60
+ }
61
+
62
+ export const D1_OPS_EVENTS_SCHEMA = `
63
+ CREATE TABLE IF NOT EXISTS ops_events (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ event_type TEXT NOT NULL,
66
+ severity TEXT NOT NULL DEFAULT 'info',
67
+ source TEXT NOT NULL,
68
+ fingerprint TEXT,
69
+ payload_json TEXT NOT NULL DEFAULT '{}',
70
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
71
+ );
72
+ CREATE INDEX IF NOT EXISTS ops_events_created_at_idx ON ops_events(created_at DESC);
73
+ CREATE INDEX IF NOT EXISTS ops_events_type_created_idx ON ops_events(event_type, created_at DESC);
74
+ CREATE INDEX IF NOT EXISTS ops_events_severity_created_idx ON ops_events(severity, created_at DESC);
75
+ `;
@@ -0,0 +1,198 @@
1
+ import { Kysely } from "kysely";
2
+ import { PostgresJSDialect } from "kysely-postgres-js";
3
+ import postgres from "postgres";
4
+
5
+ export type PgConnectionSource = "hyperdrive" | "url" | "fallback";
6
+ export type PgSslMode = "auto" | "disable" | "require" | "verify-ca";
7
+
8
+ export type HyperdriveLike = {
9
+ connectionString?: string;
10
+ };
11
+
12
+ export type PgEnvLike = Record<string, unknown> & {
13
+ HYPERDRIVE?: HyperdriveLike;
14
+ PG_URL?: string;
15
+ PG_CA_CERT?: string;
16
+ };
17
+
18
+ export type ResolvedPgConnection = {
19
+ connectionString: string;
20
+ source: PgConnectionSource;
21
+ closeConnection: boolean;
22
+ caCert?: string;
23
+ };
24
+
25
+ export type PgConnectionOptions = {
26
+ hyperdriveKey?: string;
27
+ urlKey?: string;
28
+ caCertKey?: string;
29
+ fallbackConnectionString?: string;
30
+ requireConnection?: boolean;
31
+ closeConnection?: boolean;
32
+ };
33
+
34
+ export type PgClientOptions = {
35
+ max?: number;
36
+ prepare?: boolean;
37
+ idleTimeoutSeconds?: number;
38
+ connectTimeoutSeconds?: number;
39
+ sslMode?: PgSslMode;
40
+ };
41
+
42
+ export type KyselyPgOptions = PgConnectionOptions &
43
+ PgClientOptions & {
44
+ onDestroyError?: (error: unknown) => void;
45
+ };
46
+
47
+ type PostgresOptions = NonNullable<Parameters<typeof postgres>[1]>;
48
+
49
+ export function resolvePgConnection(
50
+ env: PgEnvLike,
51
+ options: PgConnectionOptions = {},
52
+ ): ResolvedPgConnection {
53
+ const hyperdriveKey = options.hyperdriveKey ?? "HYPERDRIVE";
54
+ const urlKey = options.urlKey ?? "PG_URL";
55
+ const caCertKey = options.caCertKey ?? "PG_CA_CERT";
56
+ const hyperdrive = env[hyperdriveKey] as HyperdriveLike | undefined;
57
+ const hyperdriveString = cleanString(hyperdrive?.connectionString);
58
+ if (hyperdriveString) {
59
+ return {
60
+ connectionString: hyperdriveString,
61
+ source: "hyperdrive",
62
+ closeConnection: options.closeConnection ?? false,
63
+ };
64
+ }
65
+
66
+ const urlString = cleanString(env[urlKey]);
67
+ if (urlString) {
68
+ return {
69
+ connectionString: urlString,
70
+ source: "url",
71
+ closeConnection: options.closeConnection ?? true,
72
+ caCert: normalizePem(cleanString(env[caCertKey])),
73
+ };
74
+ }
75
+
76
+ const fallback = cleanString(options.fallbackConnectionString);
77
+ if (fallback) {
78
+ return {
79
+ connectionString: fallback,
80
+ source: "fallback",
81
+ closeConnection: options.closeConnection ?? true,
82
+ caCert: normalizePem(cleanString(env[caCertKey])),
83
+ };
84
+ }
85
+
86
+ if (options.requireConnection ?? true) {
87
+ throw new Error(`${urlKey} or ${hyperdriveKey}.connectionString is required for Postgres.`);
88
+ }
89
+
90
+ return {
91
+ connectionString: "",
92
+ source: "fallback",
93
+ closeConnection: options.closeConnection ?? true,
94
+ };
95
+ }
96
+
97
+ export function postgresClientOptions(
98
+ connection: Pick<ResolvedPgConnection, "connectionString" | "source" | "caCert">,
99
+ options: PgClientOptions = {},
100
+ ): PostgresOptions {
101
+ const clientOptions: PostgresOptions = {
102
+ max: options.max ?? 1,
103
+ prepare: options.prepare ?? false,
104
+ idle_timeout: options.idleTimeoutSeconds ?? 20,
105
+ connect_timeout: options.connectTimeoutSeconds ?? 5,
106
+ };
107
+
108
+ const ssl = resolvePgSsl(connection, options.sslMode ?? "auto");
109
+ if (ssl !== undefined) clientOptions.ssl = ssl;
110
+ return clientOptions;
111
+ }
112
+
113
+ export function createPostgresSql(
114
+ connection: ResolvedPgConnection,
115
+ options: PgClientOptions = {},
116
+ ): postgres.Sql {
117
+ return postgres(connection.connectionString, postgresClientOptions(connection, options));
118
+ }
119
+
120
+ export function createKyselyPg<Database>(
121
+ env: PgEnvLike,
122
+ options: KyselyPgOptions = {},
123
+ ): {
124
+ db: Kysely<Database>;
125
+ sql: postgres.Sql;
126
+ connection: ResolvedPgConnection;
127
+ destroy(): Promise<void>;
128
+ } {
129
+ const connection = resolvePgConnection(env, options);
130
+ const sql = createPostgresSql(connection, options);
131
+ const db = new Kysely<Database>({
132
+ dialect: new PostgresJSDialect({ postgres: sql }),
133
+ });
134
+
135
+ return {
136
+ db,
137
+ sql,
138
+ connection,
139
+ async destroy() {
140
+ try {
141
+ await db.destroy();
142
+ } catch (error) {
143
+ options.onDestroyError?.(error);
144
+ if (!options.onDestroyError) throw error;
145
+ }
146
+ },
147
+ };
148
+ }
149
+
150
+ export async function withKyselyPg<Database, Result>(
151
+ env: PgEnvLike,
152
+ fn: (db: Kysely<Database>, context: { sql: postgres.Sql; connection: ResolvedPgConnection }) => Promise<Result>,
153
+ options: KyselyPgOptions = {},
154
+ ): Promise<Result> {
155
+ const handle = createKyselyPg<Database>(env, options);
156
+ try {
157
+ return await fn(handle.db, { sql: handle.sql, connection: handle.connection });
158
+ } finally {
159
+ if (handle.connection.closeConnection) {
160
+ await handle.destroy();
161
+ }
162
+ }
163
+ }
164
+
165
+ export function normalizePem(value?: string): string | undefined {
166
+ const trimmed = value?.trim();
167
+ if (!trimmed) return undefined;
168
+ return trimmed.includes("\\n") ? trimmed.replace(/\\n/g, "\n") : trimmed;
169
+ }
170
+
171
+ function resolvePgSsl(
172
+ connection: Pick<ResolvedPgConnection, "connectionString" | "source" | "caCert">,
173
+ mode: PgSslMode,
174
+ ): PostgresOptions["ssl"] | undefined {
175
+ if (mode === "disable") return false;
176
+ if (mode === "require") return "require";
177
+ if (mode === "verify-ca") {
178
+ if (!connection.caCert) throw new Error("PG_CA_CERT is required when sslMode is verify-ca.");
179
+ return { ca: connection.caCert, rejectUnauthorized: true };
180
+ }
181
+ if (connection.source === "hyperdrive") return false;
182
+ if (connection.caCert) return { ca: connection.caCert, rejectUnauthorized: true };
183
+ if (isLocalPgUrl(connection.connectionString)) return false;
184
+ return "require";
185
+ }
186
+
187
+ function isLocalPgUrl(connectionString: string): boolean {
188
+ try {
189
+ const host = new URL(connectionString).hostname;
190
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ function cleanString(value: unknown): string | undefined {
197
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
198
+ }
package/src/pg.ts ADDED
@@ -0,0 +1,81 @@
1
+ export interface PgQueryResult<Row = unknown> {
2
+ rows: Row[];
3
+ rowCount: number | null;
4
+ }
5
+
6
+ export interface PgClientLike {
7
+ query<Row = unknown>(text: string, values?: readonly unknown[]): Promise<PgQueryResult<Row>>;
8
+ }
9
+
10
+ export interface PgPoolLike extends PgClientLike {
11
+ connect(): Promise<PgClientLike & { release(): void }>;
12
+ }
13
+
14
+ export type PgMigration = {
15
+ id: string;
16
+ sql: string;
17
+ };
18
+
19
+ export type PgMigrationResult = {
20
+ applied: string[];
21
+ skipped: string[];
22
+ };
23
+
24
+ export async function ensurePgMigrationsTable(client: PgClientLike, tableName = "schema_migrations"): Promise<void> {
25
+ assertSafeIdentifier(tableName);
26
+ await client.query(`
27
+ CREATE TABLE IF NOT EXISTS ${tableName} (
28
+ id TEXT PRIMARY KEY,
29
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
30
+ )
31
+ `);
32
+ }
33
+
34
+ export async function applyPgMigrations(
35
+ pool: PgPoolLike,
36
+ migrations: PgMigration[],
37
+ options: { tableName?: string; lockKey?: number } = {},
38
+ ): Promise<PgMigrationResult> {
39
+ const tableName = options.tableName ?? "schema_migrations";
40
+ assertSafeIdentifier(tableName);
41
+ const client = await pool.connect();
42
+ const applied: string[] = [];
43
+ const skipped: string[] = [];
44
+
45
+ try {
46
+ await client.query("BEGIN");
47
+ if (options.lockKey !== undefined) {
48
+ await client.query("SELECT pg_advisory_xact_lock($1)", [options.lockKey]);
49
+ }
50
+ await ensurePgMigrationsTable(client, tableName);
51
+
52
+ for (const migration of migrations) {
53
+ const existing = await client.query<{ id: string }>(`SELECT id FROM ${tableName} WHERE id = $1 LIMIT 1`, [migration.id]);
54
+ if (existing.rows[0]) {
55
+ skipped.push(migration.id);
56
+ continue;
57
+ }
58
+ await client.query(migration.sql);
59
+ await client.query(`INSERT INTO ${tableName} (id) VALUES ($1)`, [migration.id]);
60
+ applied.push(migration.id);
61
+ }
62
+
63
+ await client.query("COMMIT");
64
+ return { applied, skipped };
65
+ } catch (error) {
66
+ await client.query("ROLLBACK");
67
+ throw error;
68
+ } finally {
69
+ client.release();
70
+ }
71
+ }
72
+
73
+ export function pgJson<T>(value: T): string {
74
+ return JSON.stringify(value ?? null);
75
+ }
76
+
77
+ function assertSafeIdentifier(value: string): void {
78
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
79
+ throw new Error(`Unsafe SQL identifier: ${value}`);
80
+ }
81
+ }
package/src/r2-json.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { sha256Hex } from "./ids.js";
2
+
3
+ export type R2JsonPutOptions = {
4
+ contentType?: string;
5
+ customMetadata?: Record<string, string>;
6
+ };
7
+
8
+ export async function putR2Json(
9
+ bucket: R2Bucket,
10
+ key: string,
11
+ value: unknown,
12
+ options: R2JsonPutOptions = {},
13
+ ): Promise<R2Object> {
14
+ return bucket.put(key, JSON.stringify(value), {
15
+ httpMetadata: { contentType: options.contentType ?? "application/json; charset=utf-8" },
16
+ customMetadata: options.customMetadata,
17
+ });
18
+ }
19
+
20
+ export async function getR2Json<T>(bucket: R2Bucket, key: string): Promise<T | null> {
21
+ const object = await bucket.get(key);
22
+ if (!object) return null;
23
+ return (await object.json()) as T;
24
+ }
25
+
26
+ export async function digestR2Key(prefix: string, value: string | Uint8Array, extension = "json"): Promise<string> {
27
+ const input = typeof value === "string" ? value : [...value].map((byte) => String.fromCharCode(byte)).join("");
28
+ const digest = await sha256Hex(input);
29
+ const cleanPrefix = prefix.replace(/^\/+|\/+$/g, "");
30
+ const cleanExtension = extension.replace(/^\./, "");
31
+ return `${cleanPrefix}/${digest.slice(0, 2)}/${digest}.${cleanExtension}`;
32
+ }
@@ -0,0 +1,77 @@
1
+ import type { D1DatabaseLike } from "./d1.js";
2
+ import { sha256Hex } from "./ids.js";
3
+
4
+ export type RateLimitDecision = {
5
+ allowed: boolean;
6
+ keyHash: string;
7
+ count: number;
8
+ limit: number;
9
+ resetAt: string;
10
+ };
11
+
12
+ export type FixedWindowRateLimitInput = {
13
+ namespace: string;
14
+ key: string;
15
+ limit: number;
16
+ windowSeconds: number;
17
+ now?: Date;
18
+ };
19
+
20
+ export async function checkD1FixedWindowRateLimit(
21
+ db: D1DatabaseLike,
22
+ input: FixedWindowRateLimitInput,
23
+ ): Promise<RateLimitDecision> {
24
+ const now = input.now ?? new Date();
25
+ const windowSeconds = Math.max(1, Math.floor(input.windowSeconds));
26
+ const limit = Math.max(1, Math.floor(input.limit));
27
+ const bucketStartMs = Math.floor(now.getTime() / (windowSeconds * 1000)) * windowSeconds * 1000;
28
+ const bucket = new Date(bucketStartMs).toISOString();
29
+ const resetAt = new Date(bucketStartMs + windowSeconds * 1000).toISOString();
30
+ const keyHash = await sha256Hex(`${input.namespace}:${input.key}`);
31
+
32
+ await db
33
+ .prepare(
34
+ `INSERT INTO rate_limits (namespace, key_hash, bucket, count, reset_at)
35
+ VALUES (?, ?, ?, 1, ?)
36
+ ON CONFLICT(namespace, key_hash, bucket)
37
+ DO UPDATE SET count = count + 1, reset_at = excluded.reset_at`,
38
+ )
39
+ .bind(input.namespace, keyHash, bucket, resetAt)
40
+ .run();
41
+
42
+ const row = await db
43
+ .prepare(
44
+ `SELECT count, reset_at AS resetAt
45
+ FROM rate_limits
46
+ WHERE namespace = ? AND key_hash = ? AND bucket = ?
47
+ LIMIT 1`,
48
+ )
49
+ .bind(input.namespace, keyHash, bucket)
50
+ .first<{ count: number; resetAt: string }>();
51
+
52
+ const count = Number(row?.count ?? 1);
53
+ return {
54
+ allowed: count <= limit,
55
+ keyHash,
56
+ count,
57
+ limit,
58
+ resetAt: row?.resetAt ?? resetAt,
59
+ };
60
+ }
61
+
62
+ export async function deleteExpiredD1RateLimitBuckets(db: D1DatabaseLike, now = new Date()): Promise<number> {
63
+ const result = await db.prepare("DELETE FROM rate_limits WHERE reset_at < ?").bind(now.toISOString()).run();
64
+ return Number(result.meta.changes ?? 0);
65
+ }
66
+
67
+ export const D1_RATE_LIMITS_SCHEMA = `
68
+ CREATE TABLE IF NOT EXISTS rate_limits (
69
+ namespace TEXT NOT NULL,
70
+ key_hash TEXT NOT NULL,
71
+ bucket TEXT NOT NULL,
72
+ count INTEGER NOT NULL DEFAULT 0,
73
+ reset_at TEXT NOT NULL,
74
+ PRIMARY KEY (namespace, key_hash, bucket)
75
+ );
76
+ CREATE INDEX IF NOT EXISTS rate_limits_reset_at_idx ON rate_limits(reset_at);
77
+ `;
package/src/seo.ts ADDED
@@ -0,0 +1,73 @@
1
+ export type SitemapUrl = {
2
+ loc: string;
3
+ lastmod?: string;
4
+ changefreq?: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
5
+ priority?: number;
6
+ };
7
+
8
+ export type PageMeta = {
9
+ title: string;
10
+ description?: string;
11
+ canonical?: string;
12
+ image?: string;
13
+ type?: string;
14
+ noindex?: boolean;
15
+ };
16
+
17
+ export function xmlEscape(value: string): string {
18
+ return value
19
+ .replace(/&/g, "&amp;")
20
+ .replace(/</g, "&lt;")
21
+ .replace(/>/g, "&gt;")
22
+ .replace(/"/g, "&quot;")
23
+ .replace(/'/g, "&apos;");
24
+ }
25
+
26
+ export function renderSitemapXml(urls: SitemapUrl[]): string {
27
+ const body = urls
28
+ .map((url) => {
29
+ const fields = [
30
+ `<loc>${xmlEscape(url.loc)}</loc>`,
31
+ url.lastmod ? `<lastmod>${xmlEscape(url.lastmod)}</lastmod>` : "",
32
+ url.changefreq ? `<changefreq>${url.changefreq}</changefreq>` : "",
33
+ url.priority === undefined ? "" : `<priority>${clampPriority(url.priority).toFixed(1)}</priority>`,
34
+ ].filter(Boolean);
35
+ return ` <url>\n ${fields.join("\n ")}\n </url>`;
36
+ })
37
+ .join("\n");
38
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`;
39
+ }
40
+
41
+ export function renderRobotsTxt(options: { allow?: string[]; disallow?: string[]; sitemap?: string | string[] } = {}): string {
42
+ const lines = ["User-agent: *"];
43
+ for (const path of options.allow ?? []) lines.push(`Allow: ${path}`);
44
+ for (const path of options.disallow ?? []) lines.push(`Disallow: ${path}`);
45
+ for (const sitemap of Array.isArray(options.sitemap) ? options.sitemap : options.sitemap ? [options.sitemap] : []) {
46
+ lines.push(`Sitemap: ${sitemap}`);
47
+ }
48
+ return `${lines.join("\n")}\n`;
49
+ }
50
+
51
+ export function metaTags(meta: PageMeta): Record<string, string> {
52
+ const tags: Record<string, string> = {
53
+ title: meta.title,
54
+ "og:title": meta.title,
55
+ };
56
+ if (meta.description) {
57
+ tags.description = meta.description;
58
+ tags["og:description"] = meta.description;
59
+ }
60
+ if (meta.canonical) {
61
+ tags.canonical = meta.canonical;
62
+ tags["og:url"] = meta.canonical;
63
+ }
64
+ if (meta.image) tags["og:image"] = meta.image;
65
+ if (meta.type) tags["og:type"] = meta.type;
66
+ if (meta.noindex) tags.robots = "noindex,nofollow";
67
+ return tags;
68
+ }
69
+
70
+ function clampPriority(priority: number): number {
71
+ if (!Number.isFinite(priority)) return 0.5;
72
+ return Math.max(0, Math.min(1, priority));
73
+ }
package/src/session.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { fromBase64Url, toBase64Url } from "./ids.js";
2
+ import { epochSeconds } from "./time.js";
3
+
4
+ export type SignedSessionOptions = {
5
+ expiresInSeconds?: number;
6
+ };
7
+
8
+ type SessionEnvelope<T> = {
9
+ payload: T;
10
+ iat: number;
11
+ exp: number;
12
+ };
13
+
14
+ export async function signSession<T>(payload: T, secret: string, options: SignedSessionOptions = {}): Promise<string> {
15
+ if (!secret) throw new Error("Session secret is required.");
16
+ const now = epochSeconds();
17
+ const envelope: SessionEnvelope<T> = {
18
+ payload,
19
+ iat: now,
20
+ exp: now + (options.expiresInSeconds ?? 30 * 24 * 60 * 60),
21
+ };
22
+ const encoded = toBase64Url(JSON.stringify(envelope));
23
+ const signature = await hmacSha256(encoded, secret);
24
+ return `${encoded}.${signature}`;
25
+ }
26
+
27
+ export async function createSignedPayload(payload: string, secret: string): Promise<string> {
28
+ const encodedPayload = toBase64Url(payload);
29
+ const signature = await hmacSha256(encodedPayload, secret);
30
+ return `${encodedPayload}.${signature}`;
31
+ }
32
+
33
+ export async function verifySignedPayload(
34
+ token: string | null | undefined,
35
+ secret: string,
36
+ ): Promise<string | null> {
37
+ if (!token) return null;
38
+ const [encodedPayload, providedSignature] = token.split(".");
39
+ if (!encodedPayload || !providedSignature) return null;
40
+ const expectedSignature = await hmacSha256(encodedPayload, secret);
41
+ if (!constantTimeEqual(expectedSignature, providedSignature)) return null;
42
+ return new TextDecoder().decode(fromBase64Url(encodedPayload));
43
+ }
44
+
45
+ export async function verifySession<T>(token: string | null | undefined, secret: string): Promise<T | null> {
46
+ if (!token || !secret) return null;
47
+ const [encoded, signature] = token.split(".");
48
+ if (!encoded || !signature) return null;
49
+ const expected = await hmacSha256(encoded, secret);
50
+ if (!constantTimeEqual(signature, expected)) return null;
51
+
52
+ try {
53
+ const envelope = JSON.parse(new TextDecoder().decode(fromBase64Url(encoded))) as SessionEnvelope<T>;
54
+ if (!envelope || typeof envelope.exp !== "number" || envelope.exp < epochSeconds()) return null;
55
+ return envelope.payload;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export function sessionCookie(name: string, value: string, options: { maxAgeSeconds?: number; secure?: boolean } = {}): string {
62
+ const secure = options.secure ? "; Secure" : "";
63
+ return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${options.maxAgeSeconds ?? 30 * 24 * 60 * 60}${secure}`;
64
+ }
65
+
66
+ export function expiredSessionCookie(name: string, secure = false): string {
67
+ return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure ? "; Secure" : ""}`;
68
+ }
69
+
70
+ async function hmacSha256(input: string, secret: string): Promise<string> {
71
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
72
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(input));
73
+ return toBase64Url(new Uint8Array(signature));
74
+ }
75
+
76
+ function constantTimeEqual(a: string, b: string): boolean {
77
+ if (a.length !== b.length) return false;
78
+ let diff = 0;
79
+ for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
80
+ return diff === 0;
81
+ }