tina4-nodejs 3.13.133 → 3.13.134
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/CLAUDE.md +3 -3
- package/README.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +3181 -3051
- package/packages/cli/src/commands/generate.ts +33 -22
- package/packages/cli/src/commands/lint.ts +77 -111
- package/packages/core/dist/index.js +3090 -2952
- package/packages/core/src/.tina4-metrics.json +15004 -0
- package/packages/core/src/aiClient.ts +199 -161
- package/packages/core/src/dispatchPipeline.ts +65 -67
- package/packages/core/src/docs.ts +52 -544
- package/packages/core/src/docsParser.ts +270 -0
- package/packages/core/src/docsScanner.ts +121 -0
- package/packages/core/src/docsSignatures.ts +165 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/logger.ts +68 -82
- package/packages/core/src/mcp.ts +32 -60
- package/packages/core/src/messenger.ts +136 -157
- package/packages/core/src/middleware.ts +56 -60
- package/packages/core/src/plan.ts +78 -70
- package/packages/core/src/projectIndex.ts +15 -288
- package/packages/core/src/projectIndexExtractors.ts +126 -0
- package/packages/core/src/projectIndexStorage.ts +122 -0
- package/packages/core/src/push.ts +281 -0
- package/packages/core/src/server.ts +182 -183
- package/packages/frond/dist/index.js +607 -770
- package/packages/frond/src/engine.ts +670 -818
- package/packages/orm/dist/index.js +3100 -2965
- package/packages/orm/src/adapters/mongodb.ts +99 -144
- package/packages/orm/src/baseModel.ts +429 -515
- package/packages/orm/src/fakeData.ts +73 -61
- package/packages/orm/src/migration.ts +96 -126
- package/packages/orm/src/seeder.ts +6 -238
- package/packages/orm/src/seederTable.ts +101 -0
- package/packages/orm/src/seederTypes.ts +14 -0
- package/packages/orm/src/validation.ts +97 -80
- package/types/core/src/aiClient.d.ts +5 -0
- package/types/core/src/docsParser.d.ts +28 -0
- package/types/core/src/docsScanner.d.ts +1 -0
- package/types/core/src/docsSignatures.d.ts +11 -0
- package/types/core/src/index.d.ts +2 -0
- package/types/core/src/messenger.d.ts +8 -0
- package/types/core/src/projectIndexExtractors.d.ts +3 -0
- package/types/core/src/projectIndexStorage.d.ts +13 -0
- package/types/core/src/push.d.ts +45 -0
- package/types/frond/src/engine.d.ts +25 -0
- package/types/orm/src/fakeData.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +3 -89
- package/types/orm/src/seederTable.d.ts +9 -0
- package/types/orm/src/seederTypes.d.ts +16 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { FileEntry, FileRoute } from "./projectIndex.js";
|
|
3
|
+
|
|
4
|
+
const ROUTE_METHODS = new Set(["get", "post", "put", "patch", "delete", "any_method", "any"]);
|
|
5
|
+
|
|
6
|
+
const JS_EXPORT_RE = /^\s*export\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/gm;
|
|
7
|
+
const JS_IMPORT_RE = /^\s*import\s+[^'"]+?['"]([^'"]+)['"]/gm;
|
|
8
|
+
const JS_ROUTE_RE = /(?:^|\W)(?:(?:[A-Za-z_$][\w$]*)\.)?(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]/g;
|
|
9
|
+
|
|
10
|
+
function extractJsTs(text: string): FileEntry {
|
|
11
|
+
const exports: string[] = [];
|
|
12
|
+
const imports: string[] = [];
|
|
13
|
+
const routes: FileRoute[] = [];
|
|
14
|
+
let m: RegExpExecArray | null;
|
|
15
|
+
JS_EXPORT_RE.lastIndex = 0;
|
|
16
|
+
while ((m = JS_EXPORT_RE.exec(text)) !== null) if (!exports.includes(m[1])) exports.push(m[1]);
|
|
17
|
+
JS_IMPORT_RE.lastIndex = 0;
|
|
18
|
+
while ((m = JS_IMPORT_RE.exec(text)) !== null) if (!imports.includes(m[1])) imports.push(m[1]);
|
|
19
|
+
JS_ROUTE_RE.lastIndex = 0;
|
|
20
|
+
while ((m = JS_ROUTE_RE.exec(text)) !== null) {
|
|
21
|
+
const method = m[1].toUpperCase();
|
|
22
|
+
const routePath = m[2];
|
|
23
|
+
if (ROUTE_METHODS.has(m[1].toLowerCase()) && !routes.some((r) => r.method === method && r.path === routePath)) {
|
|
24
|
+
routes.push({ method, path: routePath, handler: "" });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
exports.sort();
|
|
28
|
+
imports.sort();
|
|
29
|
+
return { exports, imports, routes };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TWIG_EXTENDS_RE = /\{%\s*extends\s+['"]([^'"]+)['"]\s*%\}/g;
|
|
33
|
+
const TWIG_BLOCK_RE = /\{%\s*block\s+([A-Za-z_][\w-]*)/g;
|
|
34
|
+
const TWIG_INCLUDE_RE = /\{%\s*include\s+['"]([^'"]+)['"]/g;
|
|
35
|
+
|
|
36
|
+
function extractTwig(text: string): FileEntry {
|
|
37
|
+
const extendsList: string[] = [];
|
|
38
|
+
const blocks = new Set<string>();
|
|
39
|
+
const includes = new Set<string>();
|
|
40
|
+
let m: RegExpExecArray | null;
|
|
41
|
+
TWIG_EXTENDS_RE.lastIndex = 0;
|
|
42
|
+
while ((m = TWIG_EXTENDS_RE.exec(text)) !== null) extendsList.push(m[1]);
|
|
43
|
+
TWIG_BLOCK_RE.lastIndex = 0;
|
|
44
|
+
while ((m = TWIG_BLOCK_RE.exec(text)) !== null) blocks.add(m[1]);
|
|
45
|
+
TWIG_INCLUDE_RE.lastIndex = 0;
|
|
46
|
+
while ((m = TWIG_INCLUDE_RE.exec(text)) !== null) includes.add(m[1]);
|
|
47
|
+
return { extends: extendsList, blocks: Array.from(blocks).sort(), includes: Array.from(includes).sort() };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SQL_CREATE_RE = /create\s+(?:unique\s+)?(table|index|view|trigger|sequence|procedure|function)\s+(?:if\s+not\s+exists\s+)?([A-Za-z_][\w.]*)/gi;
|
|
51
|
+
const SQL_ALTER_RE = /alter\s+(table|index|view)\s+([A-Za-z_][\w.]*)/gi;
|
|
52
|
+
|
|
53
|
+
function extractSql(text: string): FileEntry {
|
|
54
|
+
const creates: string[] = [];
|
|
55
|
+
const alters: string[] = [];
|
|
56
|
+
let m: RegExpExecArray | null;
|
|
57
|
+
SQL_CREATE_RE.lastIndex = 0;
|
|
58
|
+
while ((m = SQL_CREATE_RE.exec(text)) !== null) creates.push(`${m[1].toUpperCase()} ${m[2]}`);
|
|
59
|
+
SQL_ALTER_RE.lastIndex = 0;
|
|
60
|
+
while ((m = SQL_ALTER_RE.exec(text)) !== null) alters.push(`${m[1].toUpperCase()} ${m[2]}`);
|
|
61
|
+
return { creates, alters };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const MD_H1_RE = /^#\s+(.+)$/m;
|
|
65
|
+
const MD_H2_RE = /^##\s+(.+)$/gm;
|
|
66
|
+
|
|
67
|
+
function extractMd(text: string): FileEntry {
|
|
68
|
+
const h1 = MD_H1_RE.exec(text);
|
|
69
|
+
const sections: string[] = [];
|
|
70
|
+
let m: RegExpExecArray | null;
|
|
71
|
+
MD_H2_RE.lastIndex = 0;
|
|
72
|
+
while ((m = MD_H2_RE.exec(text)) !== null && sections.length < 30) sections.push(m[1]);
|
|
73
|
+
return { title: (h1 ? h1[1] : "").trim(), sections };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const PY_CLASS_RE = /^class\s+([A-Za-z_][\w]*)/gm;
|
|
77
|
+
const PY_FUNC_RE = /^(?:async\s+)?def\s+([A-Za-z_][\w]*)/gm;
|
|
78
|
+
const PY_IMPORT_RE = /^(?:from\s+([A-Za-z_][\w.]*)\s+import|import\s+([A-Za-z_][\w.]*))/gm;
|
|
79
|
+
const PY_DECOR_RE = /^@(?:[A-Za-z_][\w]*\.)?(get|post|put|patch|delete|any_method)\s*\(\s*['"]([^'"]+)['"]/gm;
|
|
80
|
+
|
|
81
|
+
function extractPython(text: string): FileEntry {
|
|
82
|
+
const symbols: string[] = [];
|
|
83
|
+
const imports: string[] = [];
|
|
84
|
+
const routes: FileRoute[] = [];
|
|
85
|
+
let m: RegExpExecArray | null;
|
|
86
|
+
PY_CLASS_RE.lastIndex = 0;
|
|
87
|
+
while ((m = PY_CLASS_RE.exec(text)) !== null) symbols.push(m[1]);
|
|
88
|
+
PY_FUNC_RE.lastIndex = 0;
|
|
89
|
+
while ((m = PY_FUNC_RE.exec(text)) !== null) symbols.push(m[1]);
|
|
90
|
+
PY_IMPORT_RE.lastIndex = 0;
|
|
91
|
+
while ((m = PY_IMPORT_RE.exec(text)) !== null) imports.push(m[1] || m[2]);
|
|
92
|
+
PY_DECOR_RE.lastIndex = 0;
|
|
93
|
+
while ((m = PY_DECOR_RE.exec(text)) !== null) routes.push({ method: m[1].toUpperCase(), path: m[2], handler: "" });
|
|
94
|
+
const doc = text.match(/^\s*"""\s*([^\n]+)/);
|
|
95
|
+
return { symbols, imports, routes, docstring: doc ? doc[1].trim().slice(0, 200) : "" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function extractGeneric(text: string): FileEntry {
|
|
99
|
+
for (const line of text.split(/\r?\n/)) {
|
|
100
|
+
const s = line.trim();
|
|
101
|
+
if (!s || s.startsWith("<!--")) continue;
|
|
102
|
+
return { first_line: s.slice(0, 200) };
|
|
103
|
+
}
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const EXTRACTORS: Record<string, (text: string) => FileEntry> = {
|
|
108
|
+
".ts": extractJsTs, ".js": extractJsTs, ".mjs": extractJsTs,
|
|
109
|
+
".twig": extractTwig, ".html": extractTwig, ".sql": extractSql,
|
|
110
|
+
".md": extractMd, ".py": extractPython,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function extractForPath(filePath: string, text: string): FileEntry {
|
|
114
|
+
return (EXTRACTORS[path.extname(filePath)] || extractGeneric)(text);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function languageFor(filePath: string): string {
|
|
118
|
+
const ext = path.extname(filePath);
|
|
119
|
+
const map: Record<string, string> = {
|
|
120
|
+
".py": "python", ".twig": "twig", ".html": "html", ".sql": "sql",
|
|
121
|
+
".scss": "scss", ".css": "css", ".js": "javascript", ".mjs": "javascript",
|
|
122
|
+
".ts": "typescript", ".md": "markdown", ".json": "json", ".yml": "yaml",
|
|
123
|
+
".yaml": "yaml", ".toml": "toml", ".env": "env",
|
|
124
|
+
};
|
|
125
|
+
return map[ext] || "text";
|
|
126
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as crypto from "node:crypto";
|
|
4
|
+
import { extractForPath, languageFor } from "./projectIndexExtractors.js";
|
|
5
|
+
import type { FileEntry } from "./projectIndex.js";
|
|
6
|
+
|
|
7
|
+
const INDEX_DIRNAME = ".tina4";
|
|
8
|
+
const INDEX_FILENAME = "project_index.json";
|
|
9
|
+
const SKIP_DIRS = new Set([
|
|
10
|
+
".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
|
|
11
|
+
".mypy_cache", ".ruff_cache", ".pytest_cache", "dist", "build",
|
|
12
|
+
".tina4", "logs", ".idea", ".vscode",
|
|
13
|
+
]);
|
|
14
|
+
const INDEX_EXT = new Set([
|
|
15
|
+
".py", ".twig", ".html", ".sql", ".scss", ".css", ".js", ".ts",
|
|
16
|
+
".mjs", ".md", ".json", ".yml", ".yaml", ".toml", ".env",
|
|
17
|
+
]);
|
|
18
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
19
|
+
|
|
20
|
+
export interface IndexData {
|
|
21
|
+
version: number;
|
|
22
|
+
files: Record<string, FileEntry>;
|
|
23
|
+
generated_at: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function projectRoot(): string {
|
|
27
|
+
return path.resolve(process.cwd());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function indexPath(): string {
|
|
31
|
+
const d = path.join(projectRoot(), INDEX_DIRNAME);
|
|
32
|
+
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
33
|
+
return path.join(d, INDEX_FILENAME);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function routeSummary(entry: FileEntry): string {
|
|
37
|
+
const route = entry.routes?.[0];
|
|
38
|
+
if (!route) return "";
|
|
39
|
+
const extra = entry.routes!.length > 1 ? ` (+${entry.routes!.length - 1} more)` : "";
|
|
40
|
+
return `${route.method} ${route.path}${extra}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function summarise(entry: FileEntry): string {
|
|
44
|
+
if (entry.skipped) return entry.skipped;
|
|
45
|
+
if (entry.docstring) return entry.docstring;
|
|
46
|
+
if (entry.title) return entry.title;
|
|
47
|
+
const route = routeSummary(entry);
|
|
48
|
+
if (route) return route;
|
|
49
|
+
if (entry.symbols?.length) return "defines " + entry.symbols.slice(0, 4).join(", ");
|
|
50
|
+
if (entry.exports?.length) return "exports " + entry.exports.slice(0, 4).join(", ");
|
|
51
|
+
if (entry.creates?.length) return "schema: " + entry.creates.slice(0, 3).join(", ");
|
|
52
|
+
if (entry.extends?.length) return `template, extends ${entry.extends[0]}`;
|
|
53
|
+
return entry.first_line || "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function extract(fullPath: string): FileEntry {
|
|
57
|
+
let st: fs.Stats;
|
|
58
|
+
try {
|
|
59
|
+
st = fs.statSync(fullPath);
|
|
60
|
+
} catch {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
const entry: FileEntry = {
|
|
64
|
+
path: path.relative(projectRoot(), fullPath),
|
|
65
|
+
size: st.size,
|
|
66
|
+
mtime: Math.floor(st.mtimeMs / 1000),
|
|
67
|
+
language: languageFor(fullPath),
|
|
68
|
+
};
|
|
69
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
70
|
+
entry.skipped = `too large (${st.size} bytes)`;
|
|
71
|
+
return entry;
|
|
72
|
+
}
|
|
73
|
+
let text: string;
|
|
74
|
+
try {
|
|
75
|
+
text = fs.readFileSync(fullPath, "utf-8");
|
|
76
|
+
} catch {
|
|
77
|
+
return entry;
|
|
78
|
+
}
|
|
79
|
+
entry.sha256 = crypto.createHash("sha256").update(text, "utf-8").digest("hex").slice(0, 16);
|
|
80
|
+
try {
|
|
81
|
+
Object.assign(entry, extractForPath(fullPath, text));
|
|
82
|
+
} catch (error) {
|
|
83
|
+
entry.extraction_error = (error as Error).message.slice(0, 200);
|
|
84
|
+
}
|
|
85
|
+
entry.summary = summarise(entry);
|
|
86
|
+
return entry;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function walk(dir: string, out: string[]): void {
|
|
90
|
+
let entries: fs.Dirent[];
|
|
91
|
+
try {
|
|
92
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
93
|
+
} catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
if (entry.isDirectory()) {
|
|
98
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
99
|
+
walk(path.join(dir, entry.name), out);
|
|
100
|
+
} else if (entry.isFile()) {
|
|
101
|
+
if (entry.name.startsWith(".") && entry.name !== ".env") continue;
|
|
102
|
+
const ext = path.extname(entry.name);
|
|
103
|
+
if (!INDEX_EXT.has(ext) && entry.name !== ".env") continue;
|
|
104
|
+
out.push(path.join(dir, entry.name));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function loadRaw(): IndexData {
|
|
110
|
+
const p = indexPath();
|
|
111
|
+
if (!fs.existsSync(p)) return { version: 1, files: {}, generated_at: 0 };
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(fs.readFileSync(p, "utf-8")) as IndexData;
|
|
114
|
+
} catch {
|
|
115
|
+
return { version: 1, files: {}, generated_at: 0 };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function saveRaw(data: IndexData): void {
|
|
120
|
+
data.generated_at = Math.floor(Date.now() / 1000);
|
|
121
|
+
fs.writeFileSync(indexPath(), JSON.stringify(data, null, 2), "utf-8");
|
|
122
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-neutral Web Push delivery using Node's built-in crypto and fetch.
|
|
3
|
+
*
|
|
4
|
+
* This module implements the RFC 8291 `aes128gcm` content encoding and VAPID
|
|
5
|
+
* (RFC 8292). It deliberately has no web-push package dependency: the runtime
|
|
6
|
+
* already provides P-256 ECDH, ES256 signing, AES-GCM, and HTTPS fetch.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
createCipheriv,
|
|
10
|
+
createECDH,
|
|
11
|
+
createHmac,
|
|
12
|
+
createPrivateKey,
|
|
13
|
+
createSign,
|
|
14
|
+
randomBytes,
|
|
15
|
+
} from "node:crypto";
|
|
16
|
+
|
|
17
|
+
const CURVE = "prime256v1";
|
|
18
|
+
const RECORD_SIZE = 4096;
|
|
19
|
+
const MAX_PAYLOAD = RECORD_SIZE - 17;
|
|
20
|
+
|
|
21
|
+
export interface PushSubscription {
|
|
22
|
+
endpoint: string;
|
|
23
|
+
keys: {
|
|
24
|
+
p256dh: string;
|
|
25
|
+
auth: string;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PushOptions {
|
|
30
|
+
subject?: string;
|
|
31
|
+
publicKey?: string;
|
|
32
|
+
privateKey?: string;
|
|
33
|
+
ttl?: number;
|
|
34
|
+
urgency?: "very-low" | "low" | "normal" | "high";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PushResult {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
status: number;
|
|
40
|
+
dead: boolean;
|
|
41
|
+
retryable: boolean;
|
|
42
|
+
endpoint: string;
|
|
43
|
+
response: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type PushPayload = string | Uint8Array | Record<string, unknown> | unknown[] | number | boolean | null;
|
|
47
|
+
|
|
48
|
+
export class PushError extends Error {
|
|
49
|
+
constructor(message: string) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "PushError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function encodeBase64Url(value: Uint8Array): string {
|
|
56
|
+
return Buffer.from(value).toString("base64")
|
|
57
|
+
.replace(/\+/g, "-")
|
|
58
|
+
.replace(/\//g, "_")
|
|
59
|
+
.replace(/=+$/g, "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function decodeBase64Url(value: string, name: string): Buffer {
|
|
63
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
64
|
+
throw new PushError(`${name} must be a non-empty base64url string`);
|
|
65
|
+
}
|
|
66
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
67
|
+
throw new PushError(`${name} must be base64url encoded`);
|
|
68
|
+
}
|
|
69
|
+
return Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function vapidPrivateKey(rawPrivate: Buffer, rawPublic: Buffer) {
|
|
73
|
+
if (rawPublic.length !== 65 || rawPublic[0] !== 0x04) throw new PushError("P-256 public keys must be 65-byte uncompressed points");
|
|
74
|
+
const x = encodeBase64Url(rawPublic.subarray(1, 33));
|
|
75
|
+
const y = encodeBase64Url(rawPublic.subarray(33, 65));
|
|
76
|
+
return createPrivateKey({
|
|
77
|
+
key: {
|
|
78
|
+
kty: "EC",
|
|
79
|
+
crv: "P-256",
|
|
80
|
+
d: encodeBase64Url(rawPrivate),
|
|
81
|
+
x,
|
|
82
|
+
y,
|
|
83
|
+
ext: true,
|
|
84
|
+
},
|
|
85
|
+
format: "jwk",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function hmac(salt: Buffer, value: Buffer): Buffer {
|
|
90
|
+
return createHmac("sha256", salt).update(value).digest();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function hkdfExpand(prk: Buffer, info: Buffer, length: number): Buffer {
|
|
94
|
+
const chunks: Buffer[] = [];
|
|
95
|
+
let previous: Buffer = Buffer.alloc(0);
|
|
96
|
+
for (let counter = 1; Buffer.concat(chunks).length < length; counter++) {
|
|
97
|
+
if (counter > 255) throw new PushError("HKDF output is too large");
|
|
98
|
+
previous = hmac(prk, Buffer.concat([previous, info, Buffer.from([counter])]));
|
|
99
|
+
chunks.push(previous);
|
|
100
|
+
}
|
|
101
|
+
return Buffer.concat(chunks).subarray(0, length);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function payloadBytes(payload: PushPayload): Buffer {
|
|
105
|
+
if (typeof payload === "string") return Buffer.from(payload, "utf8");
|
|
106
|
+
if (payload instanceof Uint8Array) return Buffer.from(payload);
|
|
107
|
+
try {
|
|
108
|
+
const json = JSON.stringify(payload);
|
|
109
|
+
if (json === undefined) throw new PushError("Push payload is not JSON serializable");
|
|
110
|
+
return Buffer.from(json, "utf8");
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw new PushError(`Push payload is not JSON serializable: ${String(error)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function encryptPayload(payload: Buffer, subscription: PushSubscription): Buffer {
|
|
117
|
+
if (payload.length > MAX_PAYLOAD) {
|
|
118
|
+
throw new PushError(`Push payload is too large; maximum is ${MAX_PAYLOAD} bytes`);
|
|
119
|
+
}
|
|
120
|
+
const clientPublic = decodeBase64Url(subscription.keys.p256dh, "subscription.keys.p256dh");
|
|
121
|
+
const authSecret = decodeBase64Url(subscription.keys.auth, "subscription.keys.auth");
|
|
122
|
+
if (clientPublic.length !== 65 || clientPublic[0] !== 0x04) {
|
|
123
|
+
throw new PushError("subscription.keys.p256dh must be a 65-byte P-256 public key");
|
|
124
|
+
}
|
|
125
|
+
if (authSecret.length !== 16) {
|
|
126
|
+
throw new PushError("subscription.keys.auth must be a 16-byte authentication secret");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const ephemeral = createECDH(CURVE);
|
|
130
|
+
ephemeral.generateKeys();
|
|
131
|
+
const serverPublic = ephemeral.getPublicKey(undefined, "uncompressed");
|
|
132
|
+
const sharedSecret = ephemeral.computeSecret(clientPublic);
|
|
133
|
+
const keyInfo = Buffer.concat([
|
|
134
|
+
Buffer.from("WebPush: info\0", "ascii"),
|
|
135
|
+
clientPublic,
|
|
136
|
+
serverPublic,
|
|
137
|
+
]);
|
|
138
|
+
const prkKey = hmac(authSecret, sharedSecret);
|
|
139
|
+
const ikm = hkdfExpand(prkKey, keyInfo, 32);
|
|
140
|
+
// The salt is written to the message header and is the salt used for the
|
|
141
|
+
// content-encryption PRK.
|
|
142
|
+
const salt = randomBytes(16);
|
|
143
|
+
const contentPrk = hmac(salt, ikm);
|
|
144
|
+
const cek = hkdfExpand(contentPrk, Buffer.from("Content-Encoding: aes128gcm\0", "ascii"), 16);
|
|
145
|
+
const nonce = hkdfExpand(contentPrk, Buffer.from("Content-Encoding: nonce\0", "ascii"), 12);
|
|
146
|
+
const cipher = createCipheriv("aes-128-gcm", cek, nonce);
|
|
147
|
+
const ciphertext = Buffer.concat([
|
|
148
|
+
cipher.update(Buffer.concat([payload, Buffer.from([0x02])])),
|
|
149
|
+
cipher.final(),
|
|
150
|
+
cipher.getAuthTag(),
|
|
151
|
+
]);
|
|
152
|
+
const recordSize = Buffer.alloc(4);
|
|
153
|
+
recordSize.writeUInt32BE(RECORD_SIZE, 0);
|
|
154
|
+
return Buffer.concat([
|
|
155
|
+
salt,
|
|
156
|
+
recordSize,
|
|
157
|
+
Buffer.from([serverPublic.length]),
|
|
158
|
+
serverPublic,
|
|
159
|
+
ciphertext,
|
|
160
|
+
]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function vapidToken(endpoint: string, subject: string, rawPrivate: Buffer, rawPublic: Buffer): string {
|
|
164
|
+
const audience = new URL(endpoint).origin;
|
|
165
|
+
const header = encodeBase64Url(Buffer.from(JSON.stringify({ typ: "JWT", alg: "ES256" })));
|
|
166
|
+
const claims = encodeBase64Url(Buffer.from(JSON.stringify({
|
|
167
|
+
aud: audience,
|
|
168
|
+
exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60,
|
|
169
|
+
sub: subject,
|
|
170
|
+
})));
|
|
171
|
+
const signingInput = `${header}.${claims}`;
|
|
172
|
+
const signer = createSign("SHA256");
|
|
173
|
+
signer.update(signingInput);
|
|
174
|
+
signer.end();
|
|
175
|
+
const signature = signer.sign({ key: vapidPrivateKey(rawPrivate, rawPublic), dsaEncoding: "ieee-p1363" });
|
|
176
|
+
return `${signingInput}.${encodeBase64Url(signature)}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function generateVapidKeys(): { publicKey: string; privateKey: string } {
|
|
180
|
+
const ecdh = createECDH(CURVE);
|
|
181
|
+
ecdh.generateKeys();
|
|
182
|
+
return {
|
|
183
|
+
publicKey: encodeBase64Url(ecdh.getPublicKey(undefined, "uncompressed")),
|
|
184
|
+
privateKey: encodeBase64Url(ecdh.getPrivateKey()),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Provider-neutral Web Push sender. A subscription is accepted as returned by PushManager. */
|
|
189
|
+
export class Push {
|
|
190
|
+
private readonly options: PushOptions;
|
|
191
|
+
|
|
192
|
+
constructor(options: PushOptions = {}) {
|
|
193
|
+
this.options = { ...options };
|
|
194
|
+
if (["0", "false", "off", "no"].includes((process.env.TINA4_WEB_PUSH ?? "").trim().toLowerCase())) throw new PushError("Web Push is disabled by TINA4_WEB_PUSH");
|
|
195
|
+
const configured = [
|
|
196
|
+
(options.subject ?? process.env.TINA4_VAPID_SUBJECT)?.trim(),
|
|
197
|
+
(options.publicKey ?? process.env.TINA4_VAPID_PUBLIC)?.trim(),
|
|
198
|
+
(options.privateKey ?? process.env.TINA4_VAPID_PRIVATE)?.trim(),
|
|
199
|
+
].some((value) => value !== undefined);
|
|
200
|
+
if (configured) this.requireConfiguration();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
static fromEnv(options: PushOptions = {}): Push { return new Push(options); }
|
|
204
|
+
static generateKeys(): { publicKey: string; privateKey: string } { return generateVapidKeys(); }
|
|
205
|
+
|
|
206
|
+
private requireConfiguration(): { subject: string; publicKey: string; privateKey: string } {
|
|
207
|
+
const subject = (this.options.subject ?? process.env.TINA4_VAPID_SUBJECT)?.trim();
|
|
208
|
+
const publicKey = (this.options.publicKey ?? process.env.TINA4_VAPID_PUBLIC)?.trim();
|
|
209
|
+
const privateKey = (this.options.privateKey ?? process.env.TINA4_VAPID_PRIVATE)?.trim();
|
|
210
|
+
const missing = [
|
|
211
|
+
subject ? undefined : "TINA4_VAPID_SUBJECT",
|
|
212
|
+
publicKey ? undefined : "TINA4_VAPID_PUBLIC",
|
|
213
|
+
privateKey ? undefined : "TINA4_VAPID_PRIVATE",
|
|
214
|
+
].filter((name): name is string => name !== undefined);
|
|
215
|
+
if (missing.length > 0) {
|
|
216
|
+
throw new PushError(`Web Push is configured but missing: ${missing.join(", ")}`);
|
|
217
|
+
}
|
|
218
|
+
// The guard above establishes these values for both TypeScript and the
|
|
219
|
+
// runtime; keeping this explicit avoids silently accepting partial VAPID
|
|
220
|
+
// configuration.
|
|
221
|
+
if (!subject || !publicKey || !privateKey) {
|
|
222
|
+
throw new PushError("Web Push VAPID configuration is incomplete");
|
|
223
|
+
}
|
|
224
|
+
return { subject, publicKey, privateKey };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private endpointFor(subscription: PushSubscription): URL {
|
|
228
|
+
if (!subscription || typeof subscription.endpoint !== "string") {
|
|
229
|
+
throw new PushError("A Web Push subscription with an endpoint is required");
|
|
230
|
+
}
|
|
231
|
+
let endpoint: URL;
|
|
232
|
+
try { endpoint = new URL(subscription.endpoint); } catch { throw new PushError("Push subscription endpoint must be a valid URL"); }
|
|
233
|
+
if (endpoint.protocol !== "https:" && endpoint.protocol !== "http:") {
|
|
234
|
+
throw new PushError("Push subscription endpoint must use HTTP or HTTPS");
|
|
235
|
+
}
|
|
236
|
+
return endpoint;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private vapidKeys(config: { publicKey: string; privateKey: string }): { rawPublic: Buffer; rawPrivate: Buffer } {
|
|
240
|
+
const rawPublic = decodeBase64Url(config.publicKey, "TINA4_VAPID_PUBLIC");
|
|
241
|
+
const rawPrivate = decodeBase64Url(config.privateKey, "TINA4_VAPID_PRIVATE");
|
|
242
|
+
if (rawPublic.length !== 65 || rawPublic[0] !== 0x04) throw new PushError("TINA4_VAPID_PUBLIC must be a 65-byte P-256 public key");
|
|
243
|
+
if (rawPrivate.length !== 32) throw new PushError("TINA4_VAPID_PRIVATE must be a 32-byte P-256 private key");
|
|
244
|
+
const derived = createECDH(CURVE);
|
|
245
|
+
try { derived.setPrivateKey(rawPrivate); } catch { throw new PushError("TINA4_VAPID_PRIVATE is not a valid P-256 private key"); }
|
|
246
|
+
if (!derived.getPublicKey(undefined, "uncompressed").equals(rawPublic)) throw new PushError("TINA4_VAPID_PUBLIC does not match TINA4_VAPID_PRIVATE");
|
|
247
|
+
return { rawPublic, rawPrivate };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private async deliver(endpoint: URL, config: { subject: string; publicKey: string; privateKey: string }, keys: { rawPublic: Buffer; rawPrivate: Buffer }, body: Buffer): Promise<PushResult> {
|
|
251
|
+
let response: Response;
|
|
252
|
+
try {
|
|
253
|
+
response = await fetch(endpoint, {
|
|
254
|
+
method: "POST",
|
|
255
|
+
headers: {
|
|
256
|
+
Authorization: `vapid t=${vapidToken(endpoint.toString(), config.subject, keys.rawPrivate, keys.rawPublic)}, k=${config.publicKey}`,
|
|
257
|
+
"Content-Encoding": "aes128gcm",
|
|
258
|
+
"Content-Type": "application/octet-stream",
|
|
259
|
+
TTL: String(this.options.ttl ?? 60),
|
|
260
|
+
...(this.options.urgency ? { Urgency: this.options.urgency } : {}),
|
|
261
|
+
},
|
|
262
|
+
// Node's fetch accepts Buffer at runtime, while the DOM declaration
|
|
263
|
+
// used by the published type build narrows BodyInit to ArrayBuffer
|
|
264
|
+
// backed views. Keep the binary payload intact and make that boundary
|
|
265
|
+
// explicit rather than converting the encrypted bytes to text.
|
|
266
|
+
body: body as unknown as BodyInit,
|
|
267
|
+
});
|
|
268
|
+
} catch (error) {
|
|
269
|
+
throw new PushError(`Web Push request failed: ${String(error)}`);
|
|
270
|
+
}
|
|
271
|
+
const status = response.status;
|
|
272
|
+
return { ok: response.ok, status, dead: status === 404 || status === 410, retryable: status === 408 || status === 429 || status >= 500, endpoint: endpoint.toString(), response: await response.text() };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async send(subscription: PushSubscription, payload: PushPayload): Promise<PushResult> {
|
|
276
|
+
const endpoint = this.endpointFor(subscription);
|
|
277
|
+
const config = this.requireConfiguration();
|
|
278
|
+
const keys = this.vapidKeys(config);
|
|
279
|
+
return this.deliver(endpoint, config, keys, encryptPayload(payloadBytes(payload), subscription));
|
|
280
|
+
}
|
|
281
|
+
}
|