@spfn/core 0.2.0-beta.9 → 0.3.0-beta.10
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/LICENSE +1 -1
- package/README.md +693 -279
- package/dist/app-config/index.d.ts +96 -0
- package/dist/app-config/index.js +58 -0
- package/dist/app-config/index.js.map +1 -0
- package/dist/authz/index.d.ts +34 -0
- package/dist/authz/index.js +430 -0
- package/dist/authz/index.js.map +1 -0
- package/dist/boss-BiDCbbAQ.d.ts +337 -0
- package/dist/cache/index.js +42 -30
- package/dist/cache/index.js.map +1 -1
- package/dist/codegen/index.d.ts +121 -13
- package/dist/codegen/index.js +212 -15
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.d.ts +576 -30
- package/dist/config/index.js +136 -17
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +221 -0
- package/dist/contract/index.js +558 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/db/index.d.ts +704 -94
- package/dist/db/index.js +1105 -260
- package/dist/db/index.js.map +1 -1
- package/dist/define-middleware-CVKgqo8S.d.ts +167 -0
- package/dist/env/index.d.ts +26 -2
- package/dist/env/index.js +15 -5
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.d.ts +26 -19
- package/dist/env/loader.js +55 -25
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.d.ts +10 -0
- package/dist/errors/index.js +40 -7
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +33 -3
- package/dist/event/index.js +24 -3
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +55 -3
- package/dist/event/sse/client.js +164 -45
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +12 -5
- package/dist/event/sse/index.js +280 -32
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +59 -0
- package/dist/event/ws/client.js +273 -0
- package/dist/event/ws/client.js.map +1 -0
- package/dist/event/ws/index.d.ts +94 -0
- package/dist/event/ws/index.js +272 -0
- package/dist/event/ws/index.js.map +1 -0
- package/dist/job/index.d.ts +48 -3
- package/dist/job/index.js +265 -74
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.d.ts +5 -0
- package/dist/logger/index.js +14 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.d.ts +352 -10
- package/dist/middleware/index.js +1079 -13
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +39 -3
- package/dist/nextjs/index.js +82 -29
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +69 -52
- package/dist/nextjs/server.js +140 -60
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +208 -0
- package/dist/ops/index.js +800 -0
- package/dist/ops/index.js.map +1 -0
- package/dist/route/index.d.ts +10 -694
- package/dist/route/index.js +112 -23
- package/dist/route/index.js.map +1 -1
- package/dist/route-builder-2ani2jEI.d.ts +553 -0
- package/dist/router-DJdpwuB6.d.ts +127 -0
- package/dist/security/index.d.ts +83 -0
- package/dist/security/index.js +173 -0
- package/dist/security/index.js.map +1 -0
- package/dist/server/index.d.ts +619 -31
- package/dist/server/index.js +2583 -360
- package/dist/server/index.js.map +1 -1
- package/dist/token-manager-BT5EnUAR.d.ts +278 -0
- package/dist/types-2AbaW4Ie.d.ts +205 -0
- package/dist/{types-BOPTApC2.d.ts → types-9oszaJqp.d.ts} +7 -2
- package/dist/types-ClQVomgV.d.ts +115 -0
- package/dist/types-ZQODsBft.d.ts +282 -0
- package/docs/file-upload.md +195 -333
- package/package.json +250 -208
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +354 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +741 -0
- package/src/job/README.md +613 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +608 -0
- package/src/route/README.md +738 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
- package/dist/boss-DI1r4kTS.d.ts +0 -244
- package/dist/router-Di7ENoah.d.ts +0 -151
- package/dist/types-B-e_f2dQ.d.ts +0 -121
- package/docs/cache.md +0 -133
- package/docs/codegen.md +0 -74
- package/docs/database.md +0 -346
- package/docs/entity.md +0 -539
- package/docs/env.md +0 -477
- package/docs/errors.md +0 -319
- package/docs/event.md +0 -116
- package/docs/job.md +0 -131
- package/docs/logger.md +0 -108
- package/docs/middleware.md +0 -337
- package/docs/nextjs.md +0 -241
- package/docs/repository.md +0 -496
- package/docs/route.md +0 -497
- package/docs/server.md +0 -307
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spfn.config.js` — the app's serving shape, read by both the CLI and the server.
|
|
3
|
+
*
|
|
4
|
+
* The file has existed since `spfn init` started writing it, and until now
|
|
5
|
+
* nothing read it back. It is the one committed place that describes how an app
|
|
6
|
+
* is served, which makes it the right home for the ports: an app that wants a
|
|
7
|
+
* different port says so once here instead of repeating the number across its
|
|
8
|
+
* `server.config.ts`, Dockerfile, compose file, `next.config.ts` and env
|
|
9
|
+
* example, where nothing checks that the copies still agree.
|
|
10
|
+
*
|
|
11
|
+
* This module is deliberately free of side effects. `@spfn/core/config`
|
|
12
|
+
* validates the whole environment schema the moment it is imported, which is
|
|
13
|
+
* fine inside a server and wrong inside a CLI that has not been handed an
|
|
14
|
+
* app's environment yet.
|
|
15
|
+
*
|
|
16
|
+
* @module app-config
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Ports the app is served on.
|
|
20
|
+
*
|
|
21
|
+
* Two names because two processes are started. Next.js reads `PORT` as its own,
|
|
22
|
+
* so the SPFN server cannot claim that name without the two colliding whenever
|
|
23
|
+
* they run together.
|
|
24
|
+
*/
|
|
25
|
+
interface SpfnPorts {
|
|
26
|
+
/** Next.js frontend. Passed to `next` as `-p`. */
|
|
27
|
+
next?: number;
|
|
28
|
+
/** SPFN API server. */
|
|
29
|
+
server?: number;
|
|
30
|
+
}
|
|
31
|
+
interface SpfnAppConfig {
|
|
32
|
+
ports?: SpfnPorts;
|
|
33
|
+
/** Host the SPFN API server binds. */
|
|
34
|
+
host?: string;
|
|
35
|
+
/** Deployment settings — untouched by this module, kept so the type is whole. */
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The single place each default exists.
|
|
40
|
+
*
|
|
41
|
+
* Defaults live at the bottom of the chain and nowhere else. A default declared
|
|
42
|
+
* at an input layer — a commander option, a generated entry file, an env schema
|
|
43
|
+
* — cannot be told apart from a value someone supplied, so it silently outranks
|
|
44
|
+
* whatever is below it. Three separate defects in this repository came from
|
|
45
|
+
* exactly that, including an env schema default that forced the environment to
|
|
46
|
+
* be consulted last and made an injected port unreachable.
|
|
47
|
+
*/
|
|
48
|
+
declare const PORT_DEFAULTS: {
|
|
49
|
+
readonly next: 3790;
|
|
50
|
+
readonly server: 8790;
|
|
51
|
+
};
|
|
52
|
+
declare const HOST_DEFAULT = "localhost";
|
|
53
|
+
/**
|
|
54
|
+
* Read `spfn.config.js` from an app directory.
|
|
55
|
+
*
|
|
56
|
+
* Returns an empty object when there is no file, and also when the file cannot
|
|
57
|
+
* be imported — a malformed deployment config must not stop a server from
|
|
58
|
+
* booting. The caller decides whether the absence is worth reporting.
|
|
59
|
+
*/
|
|
60
|
+
declare function loadAppConfig(cwd?: string): Promise<SpfnAppConfig>;
|
|
61
|
+
/**
|
|
62
|
+
* The port each process binds, given a loaded config and the environment.
|
|
63
|
+
*
|
|
64
|
+
* Three layers, in this order, and no more: environment variable, then
|
|
65
|
+
* `spfn.config.js`, then the default.
|
|
66
|
+
*/
|
|
67
|
+
declare function resolvePorts(config: SpfnAppConfig, env?: NodeJS.ProcessEnv): {
|
|
68
|
+
next: number;
|
|
69
|
+
server: number;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* The host the SPFN server binds, on the same three layers.
|
|
73
|
+
*
|
|
74
|
+
* `localhost` by default: a container states `SPFN_HOST=0.0.0.0` in its own
|
|
75
|
+
* files, where that decision belongs, rather than every developer machine
|
|
76
|
+
* publishing a dev server to its network.
|
|
77
|
+
*/
|
|
78
|
+
declare function resolveHost(config: SpfnAppConfig, env?: NodeJS.ProcessEnv): string;
|
|
79
|
+
/**
|
|
80
|
+
* Where the SPFN server binds, including the address a server config may still
|
|
81
|
+
* carry through `.port()` / `.host()`.
|
|
82
|
+
*
|
|
83
|
+
* Those two are deprecated and sit between `spfn.config.js` and the default for
|
|
84
|
+
* one release, so an app that has not moved yet keeps the address it had. Once
|
|
85
|
+
* they are removed this collapses back to {@link resolvePorts} and
|
|
86
|
+
* {@link resolveHost}.
|
|
87
|
+
*/
|
|
88
|
+
declare function resolveServerAddress(config: SpfnAppConfig, deprecated?: {
|
|
89
|
+
port?: number;
|
|
90
|
+
host?: string;
|
|
91
|
+
}, env?: NodeJS.ProcessEnv): {
|
|
92
|
+
port: number;
|
|
93
|
+
host: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export { HOST_DEFAULT, PORT_DEFAULTS, type SpfnAppConfig, type SpfnPorts, loadAppConfig, resolveHost, resolvePorts, resolveServerAddress };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { pathToFileURL } from 'url';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
// src/app-config/index.ts
|
|
6
|
+
var PORT_DEFAULTS = {
|
|
7
|
+
next: 3790,
|
|
8
|
+
server: 8790
|
|
9
|
+
};
|
|
10
|
+
var HOST_DEFAULT = "localhost";
|
|
11
|
+
var CONFIG_FILE_NAMES = [
|
|
12
|
+
"spfn.config.js",
|
|
13
|
+
"spfn.config.mjs"
|
|
14
|
+
];
|
|
15
|
+
async function loadAppConfig(cwd = process.cwd()) {
|
|
16
|
+
for (const fileName of CONFIG_FILE_NAMES) {
|
|
17
|
+
const fullPath = join(cwd, fileName);
|
|
18
|
+
if (!existsSync(fullPath)) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const module = await import(pathToFileURL(fullPath).href);
|
|
23
|
+
return module.default ?? {};
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.warn(
|
|
26
|
+
`\u26A0\uFE0F ${fileName} could not be imported \u2014 falling back to defaults for ports and host. ${error instanceof Error ? error.message : String(error)}`
|
|
27
|
+
);
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
function resolvePorts(config, env = process.env) {
|
|
34
|
+
return {
|
|
35
|
+
next: readPort(env.NEXT_PORT) ?? config.ports?.next ?? PORT_DEFAULTS.next,
|
|
36
|
+
server: readPort(env.SPFN_PORT) ?? config.ports?.server ?? PORT_DEFAULTS.server
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function resolveHost(config, env = process.env) {
|
|
40
|
+
return env.SPFN_HOST || config.host || HOST_DEFAULT;
|
|
41
|
+
}
|
|
42
|
+
function resolveServerAddress(config, deprecated = {}, env = process.env) {
|
|
43
|
+
return {
|
|
44
|
+
port: readPort(env.SPFN_PORT) ?? config.ports?.server ?? deprecated.port ?? PORT_DEFAULTS.server,
|
|
45
|
+
host: env.SPFN_HOST || config.host || deprecated.host || HOST_DEFAULT
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function readPort(value) {
|
|
49
|
+
if (!value) {
|
|
50
|
+
return void 0;
|
|
51
|
+
}
|
|
52
|
+
const port = Number(value);
|
|
53
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : void 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { HOST_DEFAULT, PORT_DEFAULTS, loadAppConfig, resolveHost, resolvePorts, resolveServerAddress };
|
|
57
|
+
//# sourceMappingURL=index.js.map
|
|
58
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/app-config/index.ts"],"names":[],"mappings":";;;;;AA2DO,IAAM,aAAA,GAAgB;AAAA,EACzB,IAAA,EAAM,IAAA;AAAA,EACN,MAAA,EAAQ;AACZ;AAEO,IAAM,YAAA,GAAe;AAE5B,IAAM,iBAAA,GAAoB;AAAA,EACtB,gBAAA;AAAA,EACA;AACJ,CAAA;AASA,eAAsB,aAAA,CAAc,GAAA,GAAc,OAAA,CAAQ,GAAA,EAAI,EAC9D;AACI,EAAA,KAAA,MAAW,YAAY,iBAAA,EACvB;AACI,IAAA,MAAM,QAAA,GAAW,IAAA,CAAK,GAAA,EAAK,QAAQ,CAAA;AAEnC,IAAA,IAAI,CAAC,UAAA,CAAW,QAAQ,CAAA,EACxB;AACI,MAAA;AAAA,IACJ;AAEA,IAAA,IACA;AAGI,MAAA,MAAM,MAAA,GAAS,MAAM,OAAO,aAAA,CAAc,QAAQ,CAAA,CAAE,IAAA,CAAA;AAEpD,MAAA,OAAQ,MAAA,CAAO,WAAW,EAAC;AAAA,IAC/B,SACO,KAAA,EACP;AAQI,MAAA,OAAA,CAAQ,IAAA;AAAA,QACJ,CAAA,cAAA,EAAO,QAAQ,CAAA,2EAAA,EACU,KAAA,YAAiB,QAAQ,KAAA,CAAM,OAAA,GAAU,MAAA,CAAO,KAAK,CAAC,CAAA;AAAA,OACnF;AAEA,MAAA,OAAO,EAAC;AAAA,IACZ;AAAA,EACJ;AAEA,EAAA,OAAO,EAAC;AACZ;AAQO,SAAS,YAAA,CACZ,MAAA,EACA,GAAA,GAAyB,OAAA,CAAQ,GAAA,EAErC;AACI,EAAA,OAAO;AAAA,IACH,IAAA,EAAM,SAAS,GAAA,CAAI,SAAS,KAAK,MAAA,CAAO,KAAA,EAAO,QAAQ,aAAA,CAAc,IAAA;AAAA,IACrE,MAAA,EAAQ,SAAS,GAAA,CAAI,SAAS,KAAK,MAAA,CAAO,KAAA,EAAO,UAAU,aAAA,CAAc;AAAA,GAC7E;AACJ;AASO,SAAS,WAAA,CACZ,MAAA,EACA,GAAA,GAAyB,OAAA,CAAQ,GAAA,EAErC;AACI,EAAA,OAAO,GAAA,CAAI,SAAA,IAAa,MAAA,CAAO,IAAA,IAAQ,YAAA;AAC3C;AAWO,SAAS,qBACZ,MAAA,EACA,UAAA,GAA+C,EAAC,EAChD,GAAA,GAAyB,QAAQ,GAAA,EAErC;AACI,EAAA,OAAO;AAAA,IACH,IAAA,EAAM,QAAA,CAAS,GAAA,CAAI,SAAS,CAAA,IACrB,OAAO,KAAA,EAAO,MAAA,IACd,UAAA,CAAW,IAAA,IACX,aAAA,CAAc,MAAA;AAAA,IACrB,MAAM,GAAA,CAAI,SAAA,IACH,MAAA,CAAO,IAAA,IACP,WAAW,IAAA,IACX;AAAA,GACX;AACJ;AAEA,SAAS,SAAS,KAAA,EAClB;AACI,EAAA,IAAI,CAAC,KAAA,EACL;AACI,IAAA,OAAO,MAAA;AAAA,EACX;AAEA,EAAA,MAAM,IAAA,GAAO,OAAO,KAAK,CAAA;AAEzB,EAAA,OAAO,MAAA,CAAO,UAAU,IAAI,CAAA,IAAK,OAAO,CAAA,IAAK,IAAA,GAAO,QAAQ,IAAA,GAAO,MAAA;AACvE","file":"index.js","sourcesContent":["/**\n * `spfn.config.js` — the app's serving shape, read by both the CLI and the server.\n *\n * The file has existed since `spfn init` started writing it, and until now\n * nothing read it back. It is the one committed place that describes how an app\n * is served, which makes it the right home for the ports: an app that wants a\n * different port says so once here instead of repeating the number across its\n * `server.config.ts`, Dockerfile, compose file, `next.config.ts` and env\n * example, where nothing checks that the copies still agree.\n *\n * This module is deliberately free of side effects. `@spfn/core/config`\n * validates the whole environment schema the moment it is imported, which is\n * fine inside a server and wrong inside a CLI that has not been handed an\n * app's environment yet.\n *\n * @module app-config\n */\n\nimport { existsSync } from 'fs';\nimport { pathToFileURL } from 'url';\nimport { join } from 'path';\n\n/**\n * Ports the app is served on.\n *\n * Two names because two processes are started. Next.js reads `PORT` as its own,\n * so the SPFN server cannot claim that name without the two colliding whenever\n * they run together.\n */\nexport interface SpfnPorts\n{\n /** Next.js frontend. Passed to `next` as `-p`. */\n next?: number;\n\n /** SPFN API server. */\n server?: number;\n}\n\nexport interface SpfnAppConfig\n{\n ports?: SpfnPorts;\n\n /** Host the SPFN API server binds. */\n host?: string;\n\n /** Deployment settings — untouched by this module, kept so the type is whole. */\n [key: string]: unknown;\n}\n\n/**\n * The single place each default exists.\n *\n * Defaults live at the bottom of the chain and nowhere else. A default declared\n * at an input layer — a commander option, a generated entry file, an env schema\n * — cannot be told apart from a value someone supplied, so it silently outranks\n * whatever is below it. Three separate defects in this repository came from\n * exactly that, including an env schema default that forced the environment to\n * be consulted last and made an injected port unreachable.\n */\nexport const PORT_DEFAULTS = {\n next: 3790,\n server: 8790,\n} as const;\n\nexport const HOST_DEFAULT = 'localhost';\n\nconst CONFIG_FILE_NAMES = [\n 'spfn.config.js',\n 'spfn.config.mjs',\n] as const;\n\n/**\n * Read `spfn.config.js` from an app directory.\n *\n * Returns an empty object when there is no file, and also when the file cannot\n * be imported — a malformed deployment config must not stop a server from\n * booting. The caller decides whether the absence is worth reporting.\n */\nexport async function loadAppConfig(cwd: string = process.cwd()): Promise<SpfnAppConfig>\n{\n for (const fileName of CONFIG_FILE_NAMES)\n {\n const fullPath = join(cwd, fileName);\n\n if (!existsSync(fullPath))\n {\n continue;\n }\n\n try\n {\n // A file URL, not a path: on Windows an absolute path is not a\n // valid ESM specifier, and this file is imported at runtime.\n const module = await import(pathToFileURL(fullPath).href);\n\n return (module.default ?? {}) as SpfnAppConfig;\n }\n catch (error)\n {\n // Falling back to defaults, and saying so. A file that exists and\n // cannot be imported is a typo, not an absence — silence would send\n // the server to port 8790 while its own config named another, and\n // the only symptom would be a port nobody chose.\n //\n // console, not the server logger: this runs inside the CLI too,\n // before any logger is configured.\n console.warn(\n `⚠️ ${fileName} could not be imported — falling back to defaults `\n + `for ports and host. ${error instanceof Error ? error.message : String(error)}`,\n );\n\n return {};\n }\n }\n\n return {};\n}\n\n/**\n * The port each process binds, given a loaded config and the environment.\n *\n * Three layers, in this order, and no more: environment variable, then\n * `spfn.config.js`, then the default.\n */\nexport function resolvePorts(\n config: SpfnAppConfig,\n env: NodeJS.ProcessEnv = process.env,\n): { next: number; server: number }\n{\n return {\n next: readPort(env.NEXT_PORT) ?? config.ports?.next ?? PORT_DEFAULTS.next,\n server: readPort(env.SPFN_PORT) ?? config.ports?.server ?? PORT_DEFAULTS.server,\n };\n}\n\n/**\n * The host the SPFN server binds, on the same three layers.\n *\n * `localhost` by default: a container states `SPFN_HOST=0.0.0.0` in its own\n * files, where that decision belongs, rather than every developer machine\n * publishing a dev server to its network.\n */\nexport function resolveHost(\n config: SpfnAppConfig,\n env: NodeJS.ProcessEnv = process.env,\n): string\n{\n return env.SPFN_HOST || config.host || HOST_DEFAULT;\n}\n\n/**\n * Where the SPFN server binds, including the address a server config may still\n * carry through `.port()` / `.host()`.\n *\n * Those two are deprecated and sit between `spfn.config.js` and the default for\n * one release, so an app that has not moved yet keeps the address it had. Once\n * they are removed this collapses back to {@link resolvePorts} and\n * {@link resolveHost}.\n */\nexport function resolveServerAddress(\n config: SpfnAppConfig,\n deprecated: { port?: number; host?: string } = {},\n env: NodeJS.ProcessEnv = process.env,\n): { port: number; host: string }\n{\n return {\n port: readPort(env.SPFN_PORT)\n ?? config.ports?.server\n ?? deprecated.port\n ?? PORT_DEFAULTS.server,\n host: env.SPFN_HOST\n || config.host\n || deprecated.host\n || HOST_DEFAULT,\n };\n}\n\nfunction readPort(value: string | undefined): number | undefined\n{\n if (!value)\n {\n return undefined;\n }\n\n const port = Number(value);\n\n return Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined;\n}\n"]}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @spfn/core/authz - Authorization primitives
|
|
3
|
+
*
|
|
4
|
+
* Small, domain-agnostic guards for resource ownership. The point is to make the
|
|
5
|
+
* "load, then check it belongs to the requester" step a single call so a handler
|
|
6
|
+
* can't forget it and leak another user's data (IDOR).
|
|
7
|
+
*/
|
|
8
|
+
interface RequireOwnerOptions<T> {
|
|
9
|
+
/**
|
|
10
|
+
* Property on the resource holding the owner id. Defaults to `'ownerId'`.
|
|
11
|
+
* SPFN entities commonly use `userId` / `createdBy` — pass it explicitly:
|
|
12
|
+
* `requireOwner(doc, userId, { ownerKey: 'userId' })`.
|
|
13
|
+
*/
|
|
14
|
+
ownerKey?: keyof T;
|
|
15
|
+
/** Message for the thrown NotFoundError. @default 'not found' */
|
|
16
|
+
message?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Return the resource only if it exists and belongs to `ownerId`; otherwise throw
|
|
20
|
+
* `NotFoundError`. Not-found and not-owned are deliberately the same response, so
|
|
21
|
+
* the endpoint never reveals that a resource exists to a non-owner.
|
|
22
|
+
*
|
|
23
|
+
* Ids are compared as strings, so number/string/bigint ids interoperate. A
|
|
24
|
+
* missing or null owner value never matches, so it throws.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* const chat = requireOwner(await chats.findById(id), userId, { ownerKey: 'userId' });
|
|
29
|
+
* // chat is non-null here, narrowed from T | null
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
declare function requireOwner<T extends object>(resource: T | null | undefined, ownerId: string | number | bigint, options?: RequireOwnerOptions<T>): T;
|
|
33
|
+
|
|
34
|
+
export { type RequireOwnerOptions, requireOwner };
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { logger } from '@spfn/core/logger';
|
|
2
|
+
|
|
3
|
+
// src/errors/error-registry.ts
|
|
4
|
+
var ErrorRegistry = class _ErrorRegistry {
|
|
5
|
+
errors = /* @__PURE__ */ new Map();
|
|
6
|
+
constructor(initialErrors) {
|
|
7
|
+
if (initialErrors) {
|
|
8
|
+
for (const input of initialErrors) {
|
|
9
|
+
if (input instanceof _ErrorRegistry) {
|
|
10
|
+
this.concat(input);
|
|
11
|
+
} else if (Array.isArray(input)) {
|
|
12
|
+
this.append(input);
|
|
13
|
+
} else {
|
|
14
|
+
this.append(input);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Append error class(es) to the registry
|
|
21
|
+
*
|
|
22
|
+
* @param input - Error constructor or array of constructors
|
|
23
|
+
* @returns This registry for chaining
|
|
24
|
+
*/
|
|
25
|
+
append(input) {
|
|
26
|
+
if (Array.isArray(input)) {
|
|
27
|
+
for (const ErrorClass of input) {
|
|
28
|
+
this.errors.set(ErrorClass.name, ErrorClass);
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
this.errors.set(input.name, input);
|
|
32
|
+
}
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Concatenate another ErrorRegistry into this one
|
|
37
|
+
*
|
|
38
|
+
* @param registry - Another ErrorRegistry to merge
|
|
39
|
+
* @returns This registry for chaining
|
|
40
|
+
*/
|
|
41
|
+
concat(registry) {
|
|
42
|
+
for (const [name, ErrorClass] of registry.errors) {
|
|
43
|
+
this.errors.set(name, ErrorClass);
|
|
44
|
+
}
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Check if error type is registered
|
|
49
|
+
*
|
|
50
|
+
* @param name - Error class name
|
|
51
|
+
*/
|
|
52
|
+
has(name) {
|
|
53
|
+
return this.errors.has(name);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Deserialize error from JSON data
|
|
57
|
+
*
|
|
58
|
+
* @param data - Serialized error data with __type field
|
|
59
|
+
* @returns Deserialized error instance
|
|
60
|
+
* @throws Error if error type is not registered
|
|
61
|
+
*/
|
|
62
|
+
deserialize(data) {
|
|
63
|
+
const ErrorClass = this.errors.get(data.__type);
|
|
64
|
+
if (!ErrorClass) {
|
|
65
|
+
throw new Error(`Unknown error type: ${data.__type}`);
|
|
66
|
+
}
|
|
67
|
+
const { __type: _type, error: _envelope, ...fields } = data;
|
|
68
|
+
return new ErrorClass(fields);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Try to deserialize error, return null if type unknown
|
|
72
|
+
*
|
|
73
|
+
* @param data - Serialized error data
|
|
74
|
+
* @returns Deserialized error or null
|
|
75
|
+
*/
|
|
76
|
+
tryDeserialize(data) {
|
|
77
|
+
if (!data.__type || !this.has(data.__type)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return this.deserialize(data);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Get all registered error types
|
|
88
|
+
*/
|
|
89
|
+
getRegisteredTypes() {
|
|
90
|
+
return Array.from(this.errors.keys());
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
var RESERVED_RESPONSE_KEYS = /* @__PURE__ */ new Set(["__type", "message", "error"]);
|
|
94
|
+
var AUTHORING_ENVIRONMENTS = /* @__PURE__ */ new Set(["local", "development", "test"]);
|
|
95
|
+
function refuseReservedKey(className, key) {
|
|
96
|
+
const detail = `${className} declares the reserved field "${key}". Reserved response keys: ${[...RESERVED_RESPONSE_KEYS].join(", ")}. Rename the field.`;
|
|
97
|
+
if (AUTHORING_ENVIRONMENTS.has(process.env.NODE_ENV ?? "local")) {
|
|
98
|
+
throw new Error(detail);
|
|
99
|
+
}
|
|
100
|
+
logger.error(`[SerializableError] ${detail} The field is omitted from the response.`);
|
|
101
|
+
}
|
|
102
|
+
var SerializableError = class extends Error {
|
|
103
|
+
/**
|
|
104
|
+
* Serialize error to JSON-compatible object
|
|
105
|
+
*
|
|
106
|
+
* Automatically includes:
|
|
107
|
+
* - __type: Constructor name for deserialization
|
|
108
|
+
* - message: Error message
|
|
109
|
+
* - All public instance properties (except name, stack)
|
|
110
|
+
*/
|
|
111
|
+
toJSON() {
|
|
112
|
+
const json = {
|
|
113
|
+
__type: this.constructor.name,
|
|
114
|
+
message: this.message
|
|
115
|
+
};
|
|
116
|
+
for (const key of Object.keys(this)) {
|
|
117
|
+
if (key === "name" || key === "stack" || key === "statusCode") {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (RESERVED_RESPONSE_KEYS.has(key)) {
|
|
121
|
+
refuseReservedKey(this.constructor.name, key);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
json[key] = this[key];
|
|
125
|
+
}
|
|
126
|
+
return json;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// src/errors/http-errors.ts
|
|
131
|
+
var HttpError = class extends SerializableError {
|
|
132
|
+
statusCode;
|
|
133
|
+
details;
|
|
134
|
+
constructor(data) {
|
|
135
|
+
super(data.message);
|
|
136
|
+
this.name = "HttpError";
|
|
137
|
+
this.statusCode = data.statusCode;
|
|
138
|
+
if (data.details) {
|
|
139
|
+
this.details = data.details;
|
|
140
|
+
}
|
|
141
|
+
Error.captureStackTrace(this, this.constructor);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
var BadRequestError = class extends HttpError {
|
|
145
|
+
constructor(data = {}) {
|
|
146
|
+
super({
|
|
147
|
+
message: data.message || "Bad request",
|
|
148
|
+
statusCode: 400,
|
|
149
|
+
details: data.details
|
|
150
|
+
});
|
|
151
|
+
this.name = "BadRequestError";
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
var ValidationError = class extends HttpError {
|
|
155
|
+
fields;
|
|
156
|
+
constructor(data) {
|
|
157
|
+
super({
|
|
158
|
+
message: data.message,
|
|
159
|
+
statusCode: 400,
|
|
160
|
+
details: data.details
|
|
161
|
+
});
|
|
162
|
+
this.name = "ValidationError";
|
|
163
|
+
if (data.fields) {
|
|
164
|
+
this.fields = data.fields;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
var UnauthorizedError = class extends HttpError {
|
|
169
|
+
constructor(data = {}) {
|
|
170
|
+
super({
|
|
171
|
+
message: data.message || "Authentication required",
|
|
172
|
+
statusCode: 401,
|
|
173
|
+
details: data.details
|
|
174
|
+
});
|
|
175
|
+
this.name = "UnauthorizedError";
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
var ForbiddenError = class extends HttpError {
|
|
179
|
+
constructor(data = {}) {
|
|
180
|
+
super({
|
|
181
|
+
message: data.message || "Access forbidden",
|
|
182
|
+
statusCode: 403,
|
|
183
|
+
details: data.details
|
|
184
|
+
});
|
|
185
|
+
this.name = "ForbiddenError";
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
var NotFoundError = class extends HttpError {
|
|
189
|
+
resource;
|
|
190
|
+
constructor(data = {}) {
|
|
191
|
+
super({
|
|
192
|
+
message: data.message || "Resource not found",
|
|
193
|
+
statusCode: 404,
|
|
194
|
+
details: data.details
|
|
195
|
+
});
|
|
196
|
+
this.name = "NotFoundError";
|
|
197
|
+
if (data.resource) {
|
|
198
|
+
this.resource = data.resource;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
var ConflictError = class extends HttpError {
|
|
203
|
+
constructor(data = {}) {
|
|
204
|
+
super({
|
|
205
|
+
message: data.message || "Resource conflict",
|
|
206
|
+
statusCode: 409,
|
|
207
|
+
details: data.details
|
|
208
|
+
});
|
|
209
|
+
this.name = "ConflictError";
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
var GoneError = class extends HttpError {
|
|
213
|
+
resource;
|
|
214
|
+
constructor(data = {}) {
|
|
215
|
+
super({
|
|
216
|
+
message: data.message || "Resource permanently deleted",
|
|
217
|
+
statusCode: 410,
|
|
218
|
+
details: data.details
|
|
219
|
+
});
|
|
220
|
+
this.name = "GoneError";
|
|
221
|
+
if (data.resource) {
|
|
222
|
+
this.resource = data.resource;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
var TooManyRequestsError = class extends HttpError {
|
|
227
|
+
retryAfter;
|
|
228
|
+
constructor(data = {}) {
|
|
229
|
+
super({
|
|
230
|
+
message: data.message || "Too many requests",
|
|
231
|
+
statusCode: 429,
|
|
232
|
+
details: data.details
|
|
233
|
+
});
|
|
234
|
+
this.name = "TooManyRequestsError";
|
|
235
|
+
if (data.retryAfter) {
|
|
236
|
+
this.retryAfter = data.retryAfter;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
var InternalServerError = class extends HttpError {
|
|
241
|
+
constructor(data = {}) {
|
|
242
|
+
super({
|
|
243
|
+
message: data.message || "Internal server error",
|
|
244
|
+
statusCode: 500,
|
|
245
|
+
details: data.details
|
|
246
|
+
});
|
|
247
|
+
this.name = "InternalServerError";
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
var UnsupportedMediaTypeError = class extends HttpError {
|
|
251
|
+
mediaType;
|
|
252
|
+
supportedTypes;
|
|
253
|
+
constructor(data = {}) {
|
|
254
|
+
super({
|
|
255
|
+
message: data.message || "Unsupported media type",
|
|
256
|
+
statusCode: 415,
|
|
257
|
+
details: data.details
|
|
258
|
+
});
|
|
259
|
+
this.name = "UnsupportedMediaTypeError";
|
|
260
|
+
if (data.mediaType) {
|
|
261
|
+
this.mediaType = data.mediaType;
|
|
262
|
+
}
|
|
263
|
+
if (data.supportedTypes) {
|
|
264
|
+
this.supportedTypes = data.supportedTypes;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
var UnprocessableEntityError = class extends HttpError {
|
|
269
|
+
constructor(data = {}) {
|
|
270
|
+
super({
|
|
271
|
+
message: data.message || "Unprocessable entity",
|
|
272
|
+
statusCode: 422,
|
|
273
|
+
details: data.details
|
|
274
|
+
});
|
|
275
|
+
this.name = "UnprocessableEntityError";
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
var ServiceUnavailableError = class extends HttpError {
|
|
279
|
+
retryAfter;
|
|
280
|
+
constructor(data = {}) {
|
|
281
|
+
super({
|
|
282
|
+
message: data.message || "Service unavailable",
|
|
283
|
+
statusCode: 503,
|
|
284
|
+
details: data.details
|
|
285
|
+
});
|
|
286
|
+
this.name = "ServiceUnavailableError";
|
|
287
|
+
if (data.retryAfter) {
|
|
288
|
+
this.retryAfter = data.retryAfter;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// src/errors/database-errors.ts
|
|
294
|
+
var DatabaseError = class extends SerializableError {
|
|
295
|
+
statusCode;
|
|
296
|
+
details;
|
|
297
|
+
constructor(data) {
|
|
298
|
+
super(data.message);
|
|
299
|
+
this.name = "DatabaseError";
|
|
300
|
+
this.statusCode = data.statusCode ?? 500;
|
|
301
|
+
this.details = data.details;
|
|
302
|
+
Error.captureStackTrace(this, this.constructor);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Whether this error's message/details are derived from the raw database
|
|
306
|
+
* driver (SQL text, table/column/constraint names, parameter values) and
|
|
307
|
+
* therefore must NOT be exposed to clients in production. Defined as a
|
|
308
|
+
* prototype getter so it is never serialized into the response by toJSON().
|
|
309
|
+
* Subclasses with a safe, constructed message override this to `false`.
|
|
310
|
+
*/
|
|
311
|
+
get internal() {
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
var ConnectionError = class extends DatabaseError {
|
|
316
|
+
constructor(data) {
|
|
317
|
+
super({ message: data.message, statusCode: 503, details: data.details });
|
|
318
|
+
this.name = "ConnectionError";
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
var QueryError = class extends DatabaseError {
|
|
322
|
+
constructor(data) {
|
|
323
|
+
super({
|
|
324
|
+
message: data.message,
|
|
325
|
+
statusCode: data.statusCode ?? 500,
|
|
326
|
+
details: data.details
|
|
327
|
+
});
|
|
328
|
+
this.name = "QueryError";
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
var EntityNotFoundError = class extends QueryError {
|
|
332
|
+
resource;
|
|
333
|
+
id;
|
|
334
|
+
constructor(data) {
|
|
335
|
+
super({
|
|
336
|
+
message: `${data.resource} with id ${data.id} not found`,
|
|
337
|
+
statusCode: 404,
|
|
338
|
+
details: { resource: data.resource, id: data.id }
|
|
339
|
+
});
|
|
340
|
+
this.name = "EntityNotFoundError";
|
|
341
|
+
this.resource = data.resource;
|
|
342
|
+
this.id = data.id;
|
|
343
|
+
}
|
|
344
|
+
// Message/details are constructed from the resource + id, not driver text
|
|
345
|
+
get internal() {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
var ConstraintViolationError = class extends QueryError {
|
|
350
|
+
constructor(data) {
|
|
351
|
+
super({ message: data.message, statusCode: 400, details: data.details });
|
|
352
|
+
this.name = "ConstraintViolationError";
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
var TransactionError = class extends DatabaseError {
|
|
356
|
+
constructor(data) {
|
|
357
|
+
super({
|
|
358
|
+
message: data.message,
|
|
359
|
+
statusCode: data.statusCode ?? 500,
|
|
360
|
+
details: data.details
|
|
361
|
+
});
|
|
362
|
+
this.name = "TransactionError";
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
var DeadlockError = class extends TransactionError {
|
|
366
|
+
constructor(data) {
|
|
367
|
+
super({ message: data.message, statusCode: 409, details: data.details });
|
|
368
|
+
this.name = "DeadlockError";
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
var DuplicateEntryError = class extends QueryError {
|
|
372
|
+
field;
|
|
373
|
+
value;
|
|
374
|
+
constructor(data) {
|
|
375
|
+
super({
|
|
376
|
+
message: `${data.field} already exists`,
|
|
377
|
+
statusCode: 409,
|
|
378
|
+
details: { field: data.field }
|
|
379
|
+
});
|
|
380
|
+
this.name = "DuplicateEntryError";
|
|
381
|
+
this.field = data.field;
|
|
382
|
+
this.value = data.value;
|
|
383
|
+
}
|
|
384
|
+
// Field-only message/details — safe to surface to the client (not internal)
|
|
385
|
+
get internal() {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// src/errors/index.ts
|
|
391
|
+
var errorRegistry = new ErrorRegistry();
|
|
392
|
+
errorRegistry.append([
|
|
393
|
+
HttpError,
|
|
394
|
+
BadRequestError,
|
|
395
|
+
ValidationError,
|
|
396
|
+
UnauthorizedError,
|
|
397
|
+
ForbiddenError,
|
|
398
|
+
NotFoundError,
|
|
399
|
+
ConflictError,
|
|
400
|
+
GoneError,
|
|
401
|
+
TooManyRequestsError,
|
|
402
|
+
UnsupportedMediaTypeError,
|
|
403
|
+
UnprocessableEntityError,
|
|
404
|
+
InternalServerError,
|
|
405
|
+
ServiceUnavailableError
|
|
406
|
+
]);
|
|
407
|
+
errorRegistry.append([
|
|
408
|
+
DatabaseError,
|
|
409
|
+
ConnectionError,
|
|
410
|
+
QueryError,
|
|
411
|
+
EntityNotFoundError,
|
|
412
|
+
ConstraintViolationError,
|
|
413
|
+
TransactionError,
|
|
414
|
+
DeadlockError,
|
|
415
|
+
DuplicateEntryError
|
|
416
|
+
]);
|
|
417
|
+
|
|
418
|
+
// src/authz/index.ts
|
|
419
|
+
function requireOwner(resource, ownerId, options = {}) {
|
|
420
|
+
const ownerKey = options.ownerKey ?? "ownerId";
|
|
421
|
+
const value = resource ? resource[ownerKey] : void 0;
|
|
422
|
+
if (!resource || value === null || value === void 0 || String(value) !== String(ownerId)) {
|
|
423
|
+
throw new NotFoundError({ message: options.message ?? "not found" });
|
|
424
|
+
}
|
|
425
|
+
return resource;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export { requireOwner };
|
|
429
|
+
//# sourceMappingURL=index.js.map
|
|
430
|
+
//# sourceMappingURL=index.js.map
|