@cosmicdrift/kumiko-server-runtime 0.387.0 → 0.389.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boot/boot-crypto.js +3 -3
- package/dist/boot/shared-boot-steps.d.ts +46 -0
- package/dist/boot/shared-boot-steps.js +160 -0
- package/dist/boot/wait-for-database.d.ts +5 -0
- package/dist/boot/wait-for-database.js +7 -6
- package/dist/boot/worker-boot-core.js +24 -38
- package/dist/boot-options/billing-option.d.ts +18 -9
- package/dist/boot-options/billing-option.js +44 -18
- package/dist/boot-options/demo-mode.d.ts +3 -3
- package/dist/boot-options/demo-mode.js +4 -1
- package/dist/boot-options/file-storage-option.js +6 -6
- package/dist/boot-options/option-env-schema.js +11 -2
- package/dist/boot-options/resolve-boot-options.d.ts +1 -0
- package/dist/boot-options/resolve-boot-options.js +11 -5
- package/dist/boot-options/stack-boot-requirements.d.ts +1 -1
- package/dist/boot-options/stack-boot-requirements.js +2 -14
- package/dist/build-prod-bundle.js +13 -13
- package/dist/bundled-lists-table.d.ts +2 -0
- package/dist/bundled-lists-table.js +56 -0
- package/dist/compose-auth-options.d.ts +16 -0
- package/dist/compose-auth-options.js +49 -0
- package/dist/compose-features.d.ts +5 -17
- package/dist/compose-features.js +6 -53
- package/dist/csp-hashes.js +1 -1
- package/dist/extra-routes-deps.d.ts +4 -0
- package/dist/extra-routes-deps.js +1 -0
- package/dist/fatal-error-handlers.js +7 -0
- package/dist/files-for-tenant.d.ts +3 -0
- package/dist/files-for-tenant.js +10 -0
- package/dist/renderer-web-fonts.js +3 -9
- package/dist/require-env.d.ts +2 -0
- package/dist/require-env.js +21 -0
- package/dist/resolve-installed-package.d.ts +10 -0
- package/dist/resolve-installed-package.js +32 -0
- package/dist/resolve-tailwind-cli.d.ts +3 -8
- package/dist/resolve-tailwind-cli.js +8 -25
- package/dist/run-bootstrap.d.ts +1 -1
- package/dist/run-bootstrap.js +1 -1
- package/dist/run-prod-app-auth.d.ts +17 -0
- package/dist/run-prod-app-auth.js +83 -0
- package/dist/run-prod-app-boot-context.d.ts +2 -0
- package/dist/run-prod-app-boot-context.js +15 -7
- package/dist/run-prod-app-config.d.ts +36 -0
- package/dist/run-prod-app-config.js +169 -0
- package/dist/run-prod-app-connections.d.ts +17 -0
- package/dist/run-prod-app-connections.js +59 -0
- package/dist/run-prod-app-entrypoint.d.ts +14 -0
- package/dist/run-prod-app-entrypoint.js +149 -0
- package/dist/run-prod-app-handle.d.ts +20 -0
- package/dist/run-prod-app-handle.js +105 -0
- package/dist/run-prod-app-request-context.d.ts +15 -0
- package/dist/run-prod-app-request-context.js +103 -0
- package/dist/run-prod-app-seeds.d.ts +13 -0
- package/dist/run-prod-app-seeds.js +88 -0
- package/dist/run-prod-app-static-files.js +9 -1
- package/dist/run-prod-app.d.ts +27 -19
- package/dist/run-prod-app.js +47 -727
- package/dist/run-worker-app.d.ts +12 -2
- package/dist/run-worker-app.js +2 -0
- package/package.json +27 -8
- package/src/changes.json +86 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { createAllInOneEntrypoint, createApiEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
2
|
+
import { createMetricsForFactory } from "@cosmicdrift/kumiko-framework/observability";
|
|
3
|
+
import { jobRunLoggerCallbacks } from "./boot/job-run-logger.js";
|
|
4
|
+
import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
|
|
5
|
+
import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
|
|
6
|
+
import { makeDispatchSystemQuery, makeDispatchSystemWrite } from "./extra-routes-deps.js";
|
|
7
|
+
import { makeFilesForTenant } from "./files-for-tenant.js";
|
|
8
|
+
import { buildProdAuthRoutes } from "./run-prod-app-auth.js";
|
|
9
|
+
function collectProdExtraRoutes(options, config, composed) {
|
|
10
|
+
const bootExtraRoutes = config.bootOptionWiring.extraRoutes;
|
|
11
|
+
if (!options.extraRoutes && !options.unsubscribe && bootExtraRoutes.length === 0) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
return [...(options.extraRoutes ?? []), ...composed.unsubscribeRoutes, ...bootExtraRoutes];
|
|
15
|
+
}
|
|
16
|
+
export function buildProdEntrypointOptions(options, config, composed, connections, requestContext, authWiring, effectiveFeatures) {
|
|
17
|
+
const { registry, effectiveAuth } = composed;
|
|
18
|
+
const { db, redis, outsideTransactionDb, cacheSync } = connections;
|
|
19
|
+
const { jwtIssuer, instanceId, trustedProxyHops, resolvedRateLimit } = config;
|
|
20
|
+
const extraRoutes = collectProdExtraRoutes(options, config, composed);
|
|
21
|
+
return {
|
|
22
|
+
registry,
|
|
23
|
+
context: {
|
|
24
|
+
db,
|
|
25
|
+
redis,
|
|
26
|
+
entityCache: requestContext.entityCache,
|
|
27
|
+
registry,
|
|
28
|
+
...requestContext.extraContext,
|
|
29
|
+
},
|
|
30
|
+
sseBroker: requestContext.sseBroker,
|
|
31
|
+
jwtSecret: config.jwtSecretOrKeyring,
|
|
32
|
+
...(jwtIssuer && { jwtIssuer }),
|
|
33
|
+
...(instanceId && { instanceId }),
|
|
34
|
+
dispatcherOptions: {
|
|
35
|
+
idempotency: requestContext.idempotency,
|
|
36
|
+
cacheSync,
|
|
37
|
+
outsideTransactionDb,
|
|
38
|
+
...(effectiveFeatures && { effectiveFeatures }),
|
|
39
|
+
},
|
|
40
|
+
eventDedup: requestContext.eventDedup,
|
|
41
|
+
...(options.observability && { observability: options.observability }),
|
|
42
|
+
...(options.metrics && { metrics: options.metrics }),
|
|
43
|
+
...(resolvedRateLimit && { rateLimit: resolvedRateLimit }),
|
|
44
|
+
...(trustedProxyHops !== undefined && { trustedProxyHops }),
|
|
45
|
+
...(options.errorDocs && { errorDocs: options.errorDocs }),
|
|
46
|
+
...(extraRoutes && { extraRoutes }),
|
|
47
|
+
...(effectiveAuth && {
|
|
48
|
+
auth: buildProdAuthRoutes(effectiveAuth, config, composed, connections, authWiring),
|
|
49
|
+
}),
|
|
50
|
+
...(requestContext.resolvedAnonymousAccess && {
|
|
51
|
+
anonymousAccess: requestContext.resolvedAnonymousAccess,
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function prodJobOptions(options, composed, connections) {
|
|
56
|
+
const queueNamePrefix = options.jobs?.queueNamePrefix;
|
|
57
|
+
return {
|
|
58
|
+
...jobRunLoggerCallbacks(composed.registry, connections.db),
|
|
59
|
+
...(queueNamePrefix !== undefined && { queueNamePrefix }),
|
|
60
|
+
...(options.jobs?.getActiveTenantIds !== undefined && {
|
|
61
|
+
getActiveTenantIds: options.jobs.getActiveTenantIds,
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// Deployment topology. By default, createAllInOneEntrypoint runs HTTP, both
|
|
66
|
+
// job lanes (two runners, each scheduling only its own lane's crons, so
|
|
67
|
+
// nothing fires twice) and the event dispatcher inline. That way worker-lane
|
|
68
|
+
// crons (run-export-jobs defaults to runIn:"worker") and MSPs run without a
|
|
69
|
+
// separate worker process, the gap the data export used to stall on.
|
|
70
|
+
// runSingleInstance:false means API-only: a dedicated worker must then take
|
|
71
|
+
// over the worker lane and MSPs, while API-lane jobs keep running locally.
|
|
72
|
+
// The legacy eventDispatcher.disabled maps to runSingleInstance:false so
|
|
73
|
+
// existing callers keep working; the explicit flag takes precedence.
|
|
74
|
+
export function createProdEntrypoint(options, config, composed, connections, entrypointOptions) {
|
|
75
|
+
const runSingleInstance = options.runSingleInstance ?? options.eventDispatcher?.disabled !== true;
|
|
76
|
+
const hasJobs = composed.registry.getAllJobs().size > 0;
|
|
77
|
+
const jobOptions = prodJobOptions(options, composed, connections);
|
|
78
|
+
const dispatcherTunables = options.eventDispatcher?.pollIntervalMs !== undefined
|
|
79
|
+
? { pollIntervalMs: options.eventDispatcher.pollIntervalMs }
|
|
80
|
+
: {};
|
|
81
|
+
return runSingleInstance
|
|
82
|
+
? createAllInOneEntrypoint({
|
|
83
|
+
...entrypointOptions,
|
|
84
|
+
redisUrl: config.redisUrl,
|
|
85
|
+
...jobOptions,
|
|
86
|
+
...(!options.eventDispatcher?.disabled && { eventDispatcher: dispatcherTunables }),
|
|
87
|
+
})
|
|
88
|
+
: createApiEntrypoint({
|
|
89
|
+
...entrypointOptions,
|
|
90
|
+
...(hasJobs && {
|
|
91
|
+
jobs: {
|
|
92
|
+
redisUrl: config.redisUrl,
|
|
93
|
+
runLocalJobs: true,
|
|
94
|
+
...jobOptions,
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// The broker built above is always ours (runProdApp never accepts a
|
|
100
|
+
// caller-supplied sseBroker), so — same reasoning as buildServer's own
|
|
101
|
+
// ownedRedisSseBroker hook — closing it on shutdown is our job, not the
|
|
102
|
+
// entrypoint's. Registered after createApiEntrypoint/createAllInOneEntrypoint
|
|
103
|
+
// (which already registered the eventDispatcher/jobRunner hooks above),
|
|
104
|
+
// so it drains LIFO before those, matching the close-before-teardown
|
|
105
|
+
// order established for buildServer's own hook.
|
|
106
|
+
export function registerProdShutdownHooks(entrypoint, cacheSync, ownedRedisSseBroker) {
|
|
107
|
+
entrypoint.lifecycle.registerShutdownHook("prodAppCacheSync", async () => {
|
|
108
|
+
await cacheSync.close();
|
|
109
|
+
});
|
|
110
|
+
if (ownedRedisSseBroker) {
|
|
111
|
+
const broker = ownedRedisSseBroker;
|
|
112
|
+
entrypoint.lifecycle.registerShutdownHook("prodAppRedisSseBroker", async () => {
|
|
113
|
+
await broker.close();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// `extraRoutes` itself is already mounted — buildServer wires it inside
|
|
118
|
+
// createApiEntrypoint/createAllInOneEntrypoint, before this function ever
|
|
119
|
+
// runs. `wire` is the remaining escape-hatch for app-wired co-running
|
|
120
|
+
// components that need the system-write dispatcher but don't declare a
|
|
121
|
+
// route.
|
|
122
|
+
export async function runProdWireHook(options, composed, connections, entrypoint) {
|
|
123
|
+
const { wire } = options;
|
|
124
|
+
if (wire) {
|
|
125
|
+
const { bootCrypto } = composed;
|
|
126
|
+
await wire({
|
|
127
|
+
db: connections.db,
|
|
128
|
+
redis: connections.redis,
|
|
129
|
+
registry: composed.registry,
|
|
130
|
+
filesForTenant: makeFilesForTenant(entrypoint.context),
|
|
131
|
+
...(bootCrypto.masterKeyProvider && { masterKeyProvider: bootCrypto.masterKeyProvider }),
|
|
132
|
+
...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
|
|
133
|
+
metricsFor: createMetricsForFactory(entrypoint.observability.meter),
|
|
134
|
+
dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
|
|
135
|
+
dispatchSystemQuery: makeDispatchSystemQuery(entrypoint.dispatcher),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export async function startProdBackgroundWork(entrypoint, config, composed, connections) {
|
|
140
|
+
const { envSource } = config;
|
|
141
|
+
await entrypoint.start();
|
|
142
|
+
await recordAppStartedOnBoot({ db: connections.db, envSource });
|
|
143
|
+
startPiiEventBackfillOnBoot({
|
|
144
|
+
db: connections.db,
|
|
145
|
+
registry: composed.registry,
|
|
146
|
+
lifecycle: entrypoint.lifecycle,
|
|
147
|
+
envSource,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
2
|
+
import type { ProdAppHandle, RunProdAppOptions } from "./run-prod-app.ts";
|
|
3
|
+
import type { ProdBootConfig, ProdComposedFeatures } from "./run-prod-app-config.ts";
|
|
4
|
+
import type { ProdConnections } from "./run-prod-app-connections.ts";
|
|
5
|
+
import type { ProdEntrypoint } from "./run-prod-app-entrypoint.ts";
|
|
6
|
+
export type ProdFetchHandler = {
|
|
7
|
+
readonly fetchHandler: ProdAppHandle["fetch"];
|
|
8
|
+
readonly cspScriptHashes: readonly string[];
|
|
9
|
+
};
|
|
10
|
+
export declare function buildProdFetchHandler(options: RunProdAppOptions, config: ProdBootConfig, entrypoint: ProdEntrypoint): Promise<ProdFetchHandler>;
|
|
11
|
+
export type ProdAppHandleInput = {
|
|
12
|
+
readonly options: RunProdAppOptions;
|
|
13
|
+
readonly config: ProdBootConfig;
|
|
14
|
+
readonly composed: ProdComposedFeatures;
|
|
15
|
+
readonly connections: ProdConnections;
|
|
16
|
+
readonly entrypoint: ProdEntrypoint;
|
|
17
|
+
readonly fetch: ProdFetchHandler;
|
|
18
|
+
readonly log: Logger;
|
|
19
|
+
};
|
|
20
|
+
export declare function createProdAppHandle(input: ProdAppHandleInput): ProdAppHandle;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { flushEscapeHatchAudits } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
2
|
+
import { installShutdownSignals } from "./boot-options/process-signals.js";
|
|
3
|
+
import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options.js";
|
|
4
|
+
import { installFatalErrorHandlers } from "./fatal-error-handlers.js";
|
|
5
|
+
import { uninstallProcessLogger } from "./process-logger.js";
|
|
6
|
+
import { buildStaticFallback, resolveCspScriptHashes, resolveHostDispatchScriptHashes, } from "./run-prod-app-static-files.js";
|
|
7
|
+
import { withSecurityHeaders } from "./security-headers.js";
|
|
8
|
+
import { stripNoRouteMatchHeader } from "./try-hono-first.js";
|
|
9
|
+
const SERVER_GRACEFUL_STOP_MS = 10_000;
|
|
10
|
+
// stop() without force waits for open connections (SSE never ends on its own); force-close after the grace period.
|
|
11
|
+
async function stopServerGracefully(server) {
|
|
12
|
+
let graceTimer;
|
|
13
|
+
const graceElapsed = new Promise((resolve) => {
|
|
14
|
+
graceTimer = setTimeout(resolve, SERVER_GRACEFUL_STOP_MS);
|
|
15
|
+
});
|
|
16
|
+
try {
|
|
17
|
+
await Promise.race([server.stop(), graceElapsed]);
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
clearTimeout(graceTimer);
|
|
21
|
+
}
|
|
22
|
+
await server.stop(true);
|
|
23
|
+
}
|
|
24
|
+
// Static-fallback for non-/api/ paths wired via a wrapper so Hono owns
|
|
25
|
+
// /api/* + extraRoutes and disk owns the rest. Tests use this directly;
|
|
26
|
+
// listen() wraps it in Bun.serve.
|
|
27
|
+
export async function buildProdFetchHandler(options, config, entrypoint) {
|
|
28
|
+
const cspScriptHashes = await resolveCspScriptHashes(options.staticDir, options.securityHeaders);
|
|
29
|
+
const hostDispatchScriptHashes = options.hostDispatch !== undefined && options.staticDir !== undefined
|
|
30
|
+
? await resolveHostDispatchScriptHashes(options.staticDir, options.securityHeaders, cspScriptHashes)
|
|
31
|
+
: [];
|
|
32
|
+
const fetchHandler = withSecurityHeaders(config.bootOptionWiring.wrapFetch(options.staticDir
|
|
33
|
+
? buildStaticFallback(entrypoint.app.fetch.bind(entrypoint.app), options.staticDir, options.hostDispatch, options.resolvePageHead
|
|
34
|
+
? { resolvePageHead: options.resolvePageHead, dispatcher: entrypoint.dispatcher }
|
|
35
|
+
: undefined, entrypoint.dispatcher, config.trustedProxyHops ?? 0, hostDispatchScriptHashes)
|
|
36
|
+
: // No staticDir (split-deploy / API-only container) → app.fetch's
|
|
37
|
+
// response goes straight to the client, bypassing buildStaticFallback
|
|
38
|
+
// (and with it tryHonoFirst) entirely. Must strip the router-miss
|
|
39
|
+
// marker here too, same reason as the /api/* passthrough in
|
|
40
|
+
// run-prod-app-static-files.ts (see try-hono-first.ts).
|
|
41
|
+
async (req, socketAddress) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, socketAddress))), options.securityHeaders, cspScriptHashes);
|
|
42
|
+
return { fetchHandler, cspScriptHashes };
|
|
43
|
+
}
|
|
44
|
+
export function createProdAppHandle(input) {
|
|
45
|
+
const { options, config, composed, connections, entrypoint, log } = input;
|
|
46
|
+
const { fetchHandler, cspScriptHashes } = input.fetch;
|
|
47
|
+
// Upgrade rejections (401/403/426...) are ordinary responses and get the same headers as fetchHandler.
|
|
48
|
+
// Same read-only demo gate as fetchHandler: upgrade requests are GETs, but the socket then writes.
|
|
49
|
+
const securedUpgradeFetch = withSecurityHeaders(config.bootOptionWiring.wrapFetch(async (req, env) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, env))), options.securityHeaders, cspScriptHashes);
|
|
50
|
+
const webSocketUpgradeFetch = async (req, env) => securedUpgradeFetch(req, env);
|
|
51
|
+
let uninstallShutdownSignals;
|
|
52
|
+
let uninstallFatalHandlers;
|
|
53
|
+
const handle = {
|
|
54
|
+
entrypoint,
|
|
55
|
+
fetch: fetchHandler,
|
|
56
|
+
webSocketUpgradeFetch,
|
|
57
|
+
listen: async (listenPort = config.port) => {
|
|
58
|
+
// Bun.serve provides production HTTP. Tests run under Node, so they call the
|
|
59
|
+
// fetch handler directly instead. buildBunServeOptions centralizes the
|
|
60
|
+
// options shape, including `idleTimeout: 0` for SSE.
|
|
61
|
+
if (typeof Bun === "undefined") {
|
|
62
|
+
// Throw a clear error instead of a raw ReferenceError when listen() is called
|
|
63
|
+
// under Node/vitest without autoListen:false, making the runtime
|
|
64
|
+
// requirement explicit.
|
|
65
|
+
throw new Error("[runProdApp] listen() requires Bun runtime (Bun.serve). " +
|
|
66
|
+
"Under Node/vitest pass `autoListen: false` and call the returned `fetch()` directly.");
|
|
67
|
+
}
|
|
68
|
+
handle.server = Bun.serve(buildBunServeOptions(listenPort, fetchHandler, options.maxRequestBodySize ?? resolveDerivedMaxRequestBodySize(composed.registry), { upgradeFetch: webSocketUpgradeFetch }));
|
|
69
|
+
// SIGTERM/SIGINT — graceful shutdown. Only registered when we
|
|
70
|
+
// actually own a Bun-server, otherwise the test process picks up
|
|
71
|
+
// signals it shouldn't respond to.
|
|
72
|
+
if (options.signals !== false) {
|
|
73
|
+
uninstallShutdownSignals?.();
|
|
74
|
+
uninstallShutdownSignals = installShutdownSignals({
|
|
75
|
+
stop: () => handle.stop(),
|
|
76
|
+
...(options.signals?.drainTimeoutMs !== undefined && {
|
|
77
|
+
drainTimeoutMs: options.signals.drainTimeoutMs,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
uninstallFatalHandlers?.();
|
|
81
|
+
uninstallFatalHandlers = installFatalErrorHandlers({
|
|
82
|
+
logger: log,
|
|
83
|
+
stop: () => handle.stop(),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
log.info(`ready on http://0.0.0.0:${listenPort}`);
|
|
87
|
+
},
|
|
88
|
+
stop: async () => {
|
|
89
|
+
uninstallFatalHandlers?.();
|
|
90
|
+
uninstallFatalHandlers = undefined;
|
|
91
|
+
await entrypoint.stop();
|
|
92
|
+
if (handle.server)
|
|
93
|
+
await stopServerGracefully(handle.server);
|
|
94
|
+
await flushEscapeHatchAudits();
|
|
95
|
+
await connections.closeDb();
|
|
96
|
+
await connections.closeOutsideTransactionDb();
|
|
97
|
+
connections.redis.disconnect();
|
|
98
|
+
await config.bootKms.close();
|
|
99
|
+
uninstallShutdownSignals?.();
|
|
100
|
+
uninstallShutdownSignals = undefined;
|
|
101
|
+
uninstallProcessLogger();
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
return handle;
|
|
105
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RunProdAppOptions } from "./run-prod-app.ts";
|
|
2
|
+
import type { ProdBootConfig, ProdComposedFeatures } from "./run-prod-app-config.ts";
|
|
3
|
+
import type { ProdConnections } from "./run-prod-app-connections.ts";
|
|
4
|
+
export declare function buildProdRequestContext(options: RunProdAppOptions, config: ProdBootConfig, composed: ProdComposedFeatures, connections: ProdConnections): Promise<{
|
|
5
|
+
idempotency: import("@cosmicdrift/kumiko-framework/pipeline").IdempotencyGuard;
|
|
6
|
+
eventDedup: import("@cosmicdrift/kumiko-framework/pipeline").EventDedup;
|
|
7
|
+
entityCache: import("@cosmicdrift/kumiko-types/entity-cache").EntityCache;
|
|
8
|
+
sseBroker: import("@cosmicdrift/kumiko-framework/api").SseBroker;
|
|
9
|
+
ownedRedisSseBroker: import("@cosmicdrift/kumiko-framework/api").RedisSseBroker | undefined;
|
|
10
|
+
extraContext: {
|
|
11
|
+
defaultLocale?: string | undefined;
|
|
12
|
+
};
|
|
13
|
+
resolvedAnonymousAccess: import("@cosmicdrift/kumiko-framework/api").AnonymousAccessResolved | undefined;
|
|
14
|
+
}>;
|
|
15
|
+
export type ProdRequestContext = Awaited<ReturnType<typeof buildProdRequestContext>>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { resolveAnonymousAccessFromRegistry } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
2
|
+
import { createDefaultSseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
3
|
+
import { configureBlindIndexKey, configurePiiSubjectKms, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
4
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
5
|
+
import { createFallbackLogger, setProcessLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
6
|
+
import { createEntityCache, createEventDedup, createIdempotencyGuard, } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
7
|
+
import { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, } from "./run-prod-app-boot-context.js";
|
|
8
|
+
function buildProdExtraContext(options, config, composed, connections, deps) {
|
|
9
|
+
const { envSource, bootKms } = config;
|
|
10
|
+
const { registry, features, effectiveAuth, bootCrypto, unsubscribeLinks } = composed;
|
|
11
|
+
const resolvedExtraContext = typeof options.extraContext === "function"
|
|
12
|
+
? options.extraContext(deps)
|
|
13
|
+
: (options.extraContext ?? {});
|
|
14
|
+
// Install framework defaults first; app values in resolvedExtraContext always
|
|
15
|
+
// win (for example, an app-specific configResolver).
|
|
16
|
+
// The app-wide cipher is resolved lazily, so entities without encrypted
|
|
17
|
+
// fields never access it.
|
|
18
|
+
configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
|
|
19
|
+
// Subject-key KMS for pii-annotated fields (crypto-shredding).
|
|
20
|
+
// No adapter only gets this far when assertPiiBootInvariants above let it
|
|
21
|
+
// through: no PII carriers, or an explicit allowPlaintextPii opt-out. The
|
|
22
|
+
// engine then stays off and those fields are stored in plaintext.
|
|
23
|
+
configurePiiSubjectKms(bootKms.kms);
|
|
24
|
+
// Blind-index key boot gate: with KMS enabled, lookupable fields are stored as
|
|
25
|
+
// ciphertext. Without the key, equality lookups such as login by email cannot
|
|
26
|
+
// work, so fail fast rather than allowing silently broken authentication.
|
|
27
|
+
configureBlindIndexKey(bootKms.blindIndexKey);
|
|
28
|
+
const bootLogger = loggerFromExtraContext(resolvedExtraContext);
|
|
29
|
+
if (bootLogger)
|
|
30
|
+
setProcessLogger(bootLogger);
|
|
31
|
+
const autoExtraContext = buildBootExtraContext({
|
|
32
|
+
db: connections.db,
|
|
33
|
+
features,
|
|
34
|
+
envSource,
|
|
35
|
+
registry,
|
|
36
|
+
hasAuth: !!effectiveAuth,
|
|
37
|
+
sseBroker: deps.sseBroker,
|
|
38
|
+
redis: connections.redis,
|
|
39
|
+
crypto: bootCrypto,
|
|
40
|
+
...(bootKms.kms && { kms: bootKms.kms }),
|
|
41
|
+
...(bootLogger && { log: bootLogger }),
|
|
42
|
+
...(unsubscribeLinks && { unsubscribeLinks }),
|
|
43
|
+
...(options.appOverrides && { appOverrides: options.appOverrides }),
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
...addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry),
|
|
47
|
+
...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async function resolveProdAnonymousAccess(options, deps) {
|
|
51
|
+
const baseAnonymousAccess = typeof options.anonymousAccess === "function"
|
|
52
|
+
? options.anonymousAccess(deps)
|
|
53
|
+
: options.anonymousAccess;
|
|
54
|
+
// tenantResolver / tenantExists come from auth-foundation providers.
|
|
55
|
+
return resolveAnonymousAccessFromRegistry(baseAnonymousAccess, {
|
|
56
|
+
db: deps.db,
|
|
57
|
+
registry: deps.registry,
|
|
58
|
+
warn: (message) => createFallbackLogger("runProdApp").warn(message),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export async function buildProdRequestContext(options, config, composed, connections) {
|
|
62
|
+
const { db, redis } = connections;
|
|
63
|
+
const { registry, bootCrypto } = composed;
|
|
64
|
+
// Pipeline pieces — same default config as runDevApp's setupTestStack.
|
|
65
|
+
const idempotency = createIdempotencyGuard(redis, { ttlSeconds: 60 });
|
|
66
|
+
const eventDedup = createEventDedup(redis, { ttlSeconds: 60 });
|
|
67
|
+
const entityCache = createEntityCache(redis, { ttlSeconds: 60 });
|
|
68
|
+
// When no lifecycle is supplied, createApiEntrypoint builds and owns it;
|
|
69
|
+
// the handle exposes it for SIGTERM.
|
|
70
|
+
//
|
|
71
|
+
// extraContext and anonymousAccess accept either an object or a factory that
|
|
72
|
+
// receives {db, redis, registry}; factories are useful when boot-time database
|
|
73
|
+
// access is needed, such as a subdomain-to-tenant resolver.
|
|
74
|
+
//
|
|
75
|
+
// Build the SSE broker here so extraContext factories can close over it, and
|
|
76
|
+
// pass the same broker into createApiEntrypoint (sseBroker option) so
|
|
77
|
+
// server-internal broadcasts and app-specific publishes share one broker. Use
|
|
78
|
+
// createDefaultSseBroker so REDIS_URL selects a Redis-backed broker; an
|
|
79
|
+
// unconditional in-memory broker would silently override buildServer's
|
|
80
|
+
// Redis-backed default (ServerOptions.sseBroker always wins) and prevent
|
|
81
|
+
// cross-replica fanout.
|
|
82
|
+
const { sseBroker, ownedRedisSseBroker } = createDefaultSseBroker(config.envSource);
|
|
83
|
+
const deps = {
|
|
84
|
+
db,
|
|
85
|
+
redis,
|
|
86
|
+
registry,
|
|
87
|
+
sseBroker,
|
|
88
|
+
env: config.envSource,
|
|
89
|
+
...(bootCrypto.masterKeyProvider && { masterKeyProvider: bootCrypto.masterKeyProvider }),
|
|
90
|
+
...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
|
|
91
|
+
};
|
|
92
|
+
const extraContext = buildProdExtraContext(options, config, composed, connections, deps);
|
|
93
|
+
const resolvedAnonymousAccess = await resolveProdAnonymousAccess(options, deps);
|
|
94
|
+
return {
|
|
95
|
+
idempotency,
|
|
96
|
+
eventDedup,
|
|
97
|
+
entityCache,
|
|
98
|
+
sseBroker,
|
|
99
|
+
ownedRedisSseBroker,
|
|
100
|
+
extraContext,
|
|
101
|
+
resolvedAnonymousAccess,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RunProdAppOptions } from "./run-prod-app.ts";
|
|
2
|
+
import type { ProdBootConfig, ProdComposedFeatures } from "./run-prod-app-config.ts";
|
|
3
|
+
import type { ProdConnections } from "./run-prod-app-connections.ts";
|
|
4
|
+
import type { ProdEntrypoint, ProdEntrypointOptions } from "./run-prod-app-entrypoint.ts";
|
|
5
|
+
import type { ProdRequestContext } from "./run-prod-app-request-context.ts";
|
|
6
|
+
export type ProdSeedRun = {
|
|
7
|
+
readonly options: RunProdAppOptions;
|
|
8
|
+
readonly config: ProdBootConfig;
|
|
9
|
+
readonly composed: ProdComposedFeatures;
|
|
10
|
+
readonly connections: ProdConnections;
|
|
11
|
+
readonly requestContext: ProdRequestContext;
|
|
12
|
+
};
|
|
13
|
+
export declare function runProdBootSeeds(run: ProdSeedRun, entrypoint: ProdEntrypoint, dispatcherOptions: ProdEntrypointOptions["dispatcherOptions"]): Promise<void>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { withFileProviderResolver } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { createEsOperationsTable, createSeedMigrationContext, runPendingSeedMigrations, } from "@cosmicdrift/kumiko-framework/es-ops";
|
|
3
|
+
import { createDispatcher, createUnlimitedRateLimitResolver, } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
4
|
+
import { applyStandardBootSeeds } from "./boot/shared-boot-steps.js";
|
|
5
|
+
import { makeSeedCreateFileRef, withSeedErrorContext, } from "./seed-dispatcher.js";
|
|
6
|
+
// Seeds are bootstrap writes, not tenant traffic: they always get the unlimited resolver,
|
|
7
|
+
// even when the app has a real one, so a handler's rateLimit can neither fail nor drain them.
|
|
8
|
+
function buildSeedContext(run) {
|
|
9
|
+
const { registry } = run.composed;
|
|
10
|
+
const { db, redis, cacheSync } = run.connections;
|
|
11
|
+
return withFileProviderResolver(registry, {
|
|
12
|
+
db,
|
|
13
|
+
redis,
|
|
14
|
+
entityCache: run.requestContext.entityCache,
|
|
15
|
+
registry,
|
|
16
|
+
...run.requestContext.extraContext,
|
|
17
|
+
rateLimit: createUnlimitedRateLimitResolver(),
|
|
18
|
+
}, cacheSync);
|
|
19
|
+
}
|
|
20
|
+
async function runCustomSeeds(run, seedContext, seedDispatcher) {
|
|
21
|
+
const { db } = run.connections;
|
|
22
|
+
const { registry, bootCrypto } = run.composed;
|
|
23
|
+
const createFileRef = makeSeedCreateFileRef({
|
|
24
|
+
db,
|
|
25
|
+
registry,
|
|
26
|
+
context: seedContext,
|
|
27
|
+
});
|
|
28
|
+
for (const seed of run.config.bootOptionWiring.customSeeds) {
|
|
29
|
+
await seed({
|
|
30
|
+
db,
|
|
31
|
+
registry,
|
|
32
|
+
dispatcher: seedDispatcher,
|
|
33
|
+
createFileRef,
|
|
34
|
+
...(bootCrypto.masterKeyProvider && { masterKeyProvider: bootCrypto.masterKeyProvider }),
|
|
35
|
+
...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// ES operations and seed migrations run after applyBootSeeds and the existing
|
|
40
|
+
// seeds array. Declarative seeds insert missing records; seed migrations
|
|
41
|
+
// diff and update state that existing seeds cannot revise, such as membership
|
|
42
|
+
// roles changed after initial seed creation.
|
|
43
|
+
async function runSeedMigrations(run, seedsDir, seedDispatcher) {
|
|
44
|
+
const { db } = run.connections;
|
|
45
|
+
await createEsOperationsTable(db);
|
|
46
|
+
await runPendingSeedMigrations({
|
|
47
|
+
db,
|
|
48
|
+
seedsDir,
|
|
49
|
+
appliedBy: "boot",
|
|
50
|
+
registry: run.composed.registry, // The dry-run validator catches handler QN typos before writing.
|
|
51
|
+
// @wrapper-known semantic-alias
|
|
52
|
+
createContext: (dbRunner) => createSeedMigrationContext({ dispatcher: seedDispatcher, dbRunner }),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// Seeds: admin first, then config-seeds from r.config({seeds}),
|
|
56
|
+
// then app-specific. All idempotent — runProdApp doesn't gate
|
|
57
|
+
// "first boot" via flag, every seed-step checks its own
|
|
58
|
+
// preconditions. Config-seeds rely on a deterministic
|
|
59
|
+
// aggregate-id so re-boot becomes a version_conflict skip.
|
|
60
|
+
export async function runProdBootSeeds(run, entrypoint, dispatcherOptions) {
|
|
61
|
+
const { options, config, composed, connections } = run;
|
|
62
|
+
await applyStandardBootSeeds({
|
|
63
|
+
db: connections.db,
|
|
64
|
+
registry: composed.registry,
|
|
65
|
+
admin: composed.effectiveAuth?.admin,
|
|
66
|
+
shortcutSeeds: config.bootOptionWiring.shortcutSeeds,
|
|
67
|
+
cipher: composed.bootCrypto.configCipher,
|
|
68
|
+
processName: "runProdApp",
|
|
69
|
+
...(options.databaseWait && { databaseWait: options.databaseWait }),
|
|
70
|
+
});
|
|
71
|
+
const hasSeeds = config.bootOptionWiring.customSeeds.length > 0 || options.seedsDir !== undefined;
|
|
72
|
+
const seedContext = hasSeeds ? buildSeedContext(run) : undefined;
|
|
73
|
+
const seedDispatcher = seedContext
|
|
74
|
+
? withSeedErrorContext(createDispatcher(composed.registry, seedContext, {
|
|
75
|
+
...dispatcherOptions,
|
|
76
|
+
lifecycle: entrypoint.lifecycleHooks,
|
|
77
|
+
...(entrypoint.mode === "all-in-one" && { jobRunner: entrypoint.jobRunner }),
|
|
78
|
+
}))
|
|
79
|
+
: undefined;
|
|
80
|
+
if (seedDispatcher && seedContext) {
|
|
81
|
+
await runCustomSeeds(run, seedContext, seedDispatcher);
|
|
82
|
+
}
|
|
83
|
+
if (seedDispatcher &&
|
|
84
|
+
options.seedsDir !== undefined &&
|
|
85
|
+
config.envSource["KUMIKO_SKIP_ES_OPS"] !== "1") {
|
|
86
|
+
await runSeedMigrations(run, options.seedsDir, seedDispatcher);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { join, resolve, sep } from "node:path";
|
|
2
3
|
import { cachedResponse, computeStrongEtag, computeWeakEtag, createClientIpResolver, } from "@cosmicdrift/kumiko-framework/api";
|
|
3
4
|
import { rankPreferredLocales } from "@cosmicdrift/kumiko-framework/i18n";
|
|
4
5
|
import { resolveAndInjectPageHead } from "@cosmicdrift/kumiko-renderer/headless/apex";
|
|
5
6
|
import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
6
|
-
import { ASSETS_DIR } from "./build-prod-bundle.js";
|
|
7
|
+
import { ASSETS_DIR, collectBuildScriptHashes } from "./build-prod-bundle.js";
|
|
7
8
|
import { BUNDLED_ASSETS_DIST_DIR } from "./bundled-assets.js";
|
|
8
9
|
import { CSP_HASHES_FILE, parseCspHashes } from "./csp-hashes.js";
|
|
9
10
|
import { buildRequestBoundSystemQuery, } from "./request-bound-system-query.js";
|
|
@@ -67,7 +68,14 @@ function usesDefaultCsp(securityHeaders) {
|
|
|
67
68
|
export async function resolveHostDispatchScriptHashes(staticDir, securityHeaders, defaultCspHashes) {
|
|
68
69
|
return usesDefaultCsp(securityHeaders) ? defaultCspHashes : readBuildScriptHashes(staticDir);
|
|
69
70
|
}
|
|
71
|
+
// Apps also write HTML into staticDir at boot (rendered landing pages), after kumiko build wrote
|
|
72
|
+
// the file; staticDir is server-owned, so hashing what is on disk never trusts request data.
|
|
70
73
|
export async function readBuildScriptHashes(staticDir) {
|
|
74
|
+
const fromFile = await readBuildHashesFile(staticDir);
|
|
75
|
+
const onDisk = existsSync(staticDir) ? collectBuildScriptHashes(staticDir).scriptSrc : [];
|
|
76
|
+
return [...new Set([...fromFile, ...onDisk])].sort();
|
|
77
|
+
}
|
|
78
|
+
async function readBuildHashesFile(staticDir) {
|
|
71
79
|
const file = await readStaticFile(join(staticDir, CSP_HASHES_FILE));
|
|
72
80
|
const result = file ? parseCspHashes(new TextDecoder().decode(file.bytes)) : undefined;
|
|
73
81
|
if (result?.kind === "found")
|
package/dist/run-prod-app.d.ts
CHANGED
|
@@ -1,36 +1,37 @@
|
|
|
1
1
|
/// <reference types="bun-types" preserve="true" />
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
2
|
+
import type { AccountLockoutOptions, AccountUnlockOptions, AuthMailLocale, AuthPaths, EmailVerificationOptions, InviteOptions, PasswordResetOptions, SignupOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
3
|
+
import type { SeedAdminOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
4
|
+
import type { ExtraRouteDefinition, KumikoServeEnv, PostAuthLandingResolver, SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
5
|
+
import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
6
|
+
import type { BundledListsOptions, FeatureDefinition, ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import type { AllInOneEntrypoint, ApiEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
8
8
|
import type { ComposedEnvSchema, KumikoBootError } from "@cosmicdrift/kumiko-framework/env";
|
|
9
9
|
import type { ErrorDocsConfig } from "@cosmicdrift/kumiko-framework/errors";
|
|
10
10
|
import type { JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
|
|
11
|
-
import {
|
|
11
|
+
import type { ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observability";
|
|
12
12
|
import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
13
13
|
import type { PageHeadMeta, PageHeadResolver, PageHeadSystemQuery } from "@cosmicdrift/kumiko-renderer/headless/apex";
|
|
14
|
-
import { Redis } from "ioredis";
|
|
14
|
+
import type { Redis } from "ioredis";
|
|
15
|
+
import type { DatabaseWaitClock } from "./boot/wait-for-database.ts";
|
|
15
16
|
import type { BillingBootOption } from "./boot-options/billing-option.ts";
|
|
16
17
|
import type { DemoModeOption } from "./boot-options/demo-mode.ts";
|
|
17
18
|
import type { FileStorageBootOption } from "./boot-options/file-storage-option.ts";
|
|
18
19
|
import type { KmsMode } from "./boot-options/kms-mode.ts";
|
|
19
20
|
import type { MailBootOption } from "./boot-options/mail-option.ts";
|
|
20
|
-
import {
|
|
21
|
+
import type { SignalsOption } from "./boot-options/process-signals.ts";
|
|
21
22
|
import type { ProdSeedsConfig } from "./boot-options/seed-shortcuts.ts";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
23
|
+
import type { SystemWireDeps } from "./extra-routes-deps.ts";
|
|
24
|
+
import type { RateLimitOption } from "./rate-limit-defaults.ts";
|
|
25
|
+
import type { UnsubscribeOption } from "./resolve-unsubscribe-wiring.ts";
|
|
26
|
+
import type { ConfigOverridesInput } from "./run-prod-app-boot-context.ts";
|
|
27
|
+
import type { SecurityHeadersOption } from "./security-headers.ts";
|
|
28
|
+
import type { SeedCreateFileRef } from "./seed-dispatcher.ts";
|
|
28
29
|
export { parseRunMode } from "./boot/env-gate.ts";
|
|
29
30
|
export { buildBunServeOptions } from "./bun-serve-options.ts";
|
|
31
|
+
export { requireEnv } from "./require-env.ts";
|
|
30
32
|
export { resolveUnsubscribeWiring, type UnsubscribeOption, } from "./resolve-unsubscribe-wiring.ts";
|
|
31
|
-
export { addConfigAccessorFactory, buildBootExtraContext, type ConfigOverridesInput, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.ts";
|
|
33
|
+
export { addConfigAccessorFactory, buildBootExtraContext, buildConfigOverrides, type ConfigOverridesInput, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.ts";
|
|
32
34
|
export { staticCachePolicy } from "./run-prod-app-static-files.ts";
|
|
33
|
-
export declare function requireEnv(name: string, src?: Record<string, string | undefined>, context?: string): string;
|
|
34
35
|
/** Wrapper API for the password-reset flow.
|
|
35
36
|
*
|
|
36
37
|
* Since the delivery migration PasswordResetOptions carries `appUrl`
|
|
@@ -263,6 +264,8 @@ export type RunProdAppOptions = {
|
|
|
263
264
|
readonly features: readonly FeatureDefinition[];
|
|
264
265
|
/** Opt-in boot-validator warnings — see ValidateBootOptions. */
|
|
265
266
|
readonly validateBootOptions?: ValidateBootOptions;
|
|
267
|
+
/** One table block for every list of the bundled features, see ComposeFeaturesOptions.bundledLists. */
|
|
268
|
+
readonly bundledLists?: BundledListsOptions;
|
|
266
269
|
/** Listen-Port. Default 3000 (or $PORT). */
|
|
267
270
|
readonly port?: number;
|
|
268
271
|
/** Ceiling Bun.serve buffers a request body to before any app-level
|
|
@@ -349,6 +352,9 @@ export type RunProdAppOptions = {
|
|
|
349
352
|
* cannot start against an unreachable KMS. Without an adapter,
|
|
350
353
|
* crypto-shredding remains disabled. */
|
|
351
354
|
readonly kms?: KmsAdapter;
|
|
355
|
+
/** Replaces the real clock, sleep and log sink of the boot backoff for the database, KMS and
|
|
356
|
+
* seed waits; tests use it to run the retry path without real waiting. */
|
|
357
|
+
readonly databaseWait?: DatabaseWaitClock;
|
|
352
358
|
/** 32-byte base64 key for blind-index HMACs on `lookupable` fields
|
|
353
359
|
* (env: KUMIKO_BLIND_INDEX_KEY, `openssl rand -base64 32`). It is separate
|
|
354
360
|
* from PLATFORM_KEK and not part of the KmsAdapter contract. It is required
|
|
@@ -380,7 +386,7 @@ export type RunProdAppOptions = {
|
|
|
380
386
|
* @example
|
|
381
387
|
* fileStorage: { provider: "auto" } */
|
|
382
388
|
readonly fileStorage?: FileStorageBootOption;
|
|
383
|
-
/**
|
|
389
|
+
/** Billing: `subscription-stripe` and/or `subscription-dodo` plus one tier-syncing webhook
|
|
384
390
|
* route. Needs `billing-foundation` and `tier-engine`. See `BillingBootOption`.
|
|
385
391
|
* @example
|
|
386
392
|
* billing: { stripe: { priceToTier: { [env.STRIPE_PRICE_PRO]: "pro" } }, defaultTier: "free" } */
|
|
@@ -449,7 +455,9 @@ export type RunProdAppOptions = {
|
|
|
449
455
|
* then the `APP_BASE_URL` env; with none of them no link is added (boot warning). Address
|
|
450
456
|
* recipients need a blind-index key, otherwise they get no link. Do not also mount
|
|
451
457
|
* `createUnsubscribeRoutes` via `extraRoutes`. `tenantOrigin` points a tenant's links at
|
|
452
|
-
* its own subdomain or custom domain (the host must serve this app's `/api` routes
|
|
458
|
+
* its own subdomain or custom domain (the host must serve this app's `/api` routes, and
|
|
459
|
+
* the callback must only return a verified, active domain, else null: the link carries a bearer
|
|
460
|
+
* token a stale domain's new owner would receive); null,
|
|
453
461
|
* a throw or a non-https value fall back to the app origin. The link carries the mail's
|
|
454
462
|
* locale, so the confirmation pages speak the mail's language. An app reacts to an address
|
|
455
463
|
* opt-out with an `r.job` triggered on `DeliveryHandlers.unsubscribeAddress`. */
|