create-flowdular 0.5.1 → 0.6.1
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/README.md +8 -5
- package/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.agents/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.agents/skills/database-adapter/SKILL.md +5 -5
- package/agent-template/.agents/skills/database-adapter/references/first-run-and-matrix.md +2 -2
- package/agent-template/.agents/skills/deploy-operate/SKILL.md +1 -1
- package/agent-template/.agents/skills/migration-authoring/SKILL.md +4 -4
- package/agent-template/.agents/skills/module-new/SKILL.md +1 -1
- package/agent-template/.agents/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.agents/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/.agents/skills/test-hardening/SKILL.md +2 -2
- package/agent-template/.agents/skills/workflow-development/SKILL.md +95 -9
- package/agent-template/.ai/agents/sandbox/business-manager.md +2 -2
- package/agent-template/.ai/blueprints/add-migration/required-files.yaml +1 -1
- package/agent-template/.ai/blueprints/new-module/required-files.yaml +1 -1
- package/agent-template/.ai/platform-capabilities.md +10 -8
- package/agent-template/.ai/policies/capabilities.yaml +28 -12
- package/agent-template/.ai/skills/README.md +1 -1
- package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.ai/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.ai/skills/database-adapter/SKILL.md +5 -5
- package/agent-template/.ai/skills/database-adapter/references/first-run-and-matrix.md +2 -2
- package/agent-template/.ai/skills/deploy-operate/SKILL.md +1 -1
- package/agent-template/.ai/skills/migration-authoring/SKILL.md +4 -4
- package/agent-template/.ai/skills/module-new/SKILL.md +1 -1
- package/agent-template/.ai/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.ai/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/.ai/skills/test-hardening/SKILL.md +2 -2
- package/agent-template/.ai/skills/workflow-development/SKILL.md +96 -10
- package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.claude/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.claude/skills/database-adapter/SKILL.md +5 -5
- package/agent-template/.claude/skills/database-adapter/references/first-run-and-matrix.md +2 -2
- package/agent-template/.claude/skills/deploy-operate/SKILL.md +1 -1
- package/agent-template/.claude/skills/migration-authoring/SKILL.md +4 -4
- package/agent-template/.claude/skills/module-new/SKILL.md +1 -1
- package/agent-template/.claude/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.claude/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/.claude/skills/test-hardening/SKILL.md +2 -2
- package/agent-template/.claude/skills/workflow-development/SKILL.md +95 -9
- package/agent-template/docs/adr/0003-module-settings.md +2 -0
- package/agent-template/docs/adr/0007-module-owned-agents.md +35 -1
- package/agent-template/docs/agent-contract.md +3 -3
- package/agent-template/docs/cli-extensions.md +1 -0
- package/agent-template/docs/cli.md +40 -3
- package/agent-template/docs/configuration.md +64 -9
- package/agent-template/docs/database-adapters.md +30 -22
- package/agent-template/docs/design-system.md +7 -3
- package/agent-template/docs/getting-started.md +29 -32
- package/agent-template/docs/module-distribution.md +79 -86
- package/agent-template/docs/module-web-surfaces.md +9 -7
- package/agent-template/docs/modules.md +6 -2
- package/agent-template/docs/sandbox.md +23 -4
- package/agent-template/platform/scripts/build.mjs +7 -0
- package/dist/bin.js +3 -6
- package/package.json +1 -1
- package/template/default/.env.example +8 -3
- package/template/default/.vercelignore +8 -0
- package/template/default/README.md +20 -11
- package/template/default/_gitignore +3 -2
- package/template/default/infra/README.md +86 -65
- package/template/default/infra/docker/.env.example +71 -0
- package/template/default/infra/docker/Dockerfile +29 -11
- package/template/default/infra/docker/app-entrypoint.mjs +5 -0
- package/template/default/infra/docker/compose.yaml +109 -58
- package/template/default/infra/docker/database-urls.mjs +28 -0
- package/template/default/infra/docker/pitr.sh +177 -0
- package/template/default/infra/docker/postgres/10-roles.sh +16 -12
- package/template/default/infra/docker/start.mjs +402 -0
- package/template/default/infra/kubernetes/database-secret.example.yaml +3 -3
- package/template/default/infra/kubernetes/deployment.yaml +5 -0
- package/template/default/infra/sdk-module-manifests.mjs +118 -0
- package/template/default/infra/vercel/README.md +262 -0
- package/template/default/infra/vercel/build.mjs +223 -0
- package/template/default/infra/vercel/handler.mjs +100 -0
- package/template/default/modules/example/migrations/0001_example_core.up.sql +2 -2
- package/template/default/modules/example/module.json +1 -1
- package/template/default/modules/example/package.json +3 -3
- package/template/default/modules/example/spec/module.yaml +1 -1
- package/template/default/modules/example/src/services/migration.ts +2 -2
- package/template/default/modules/example/tests/module.test.ts +1 -1
- package/template/default/package.json +2 -3
- package/template/default/platform/octane.config.ts +290 -156
- package/template/default/platform/package.json +5 -5
- package/template/default/platform/scripts/build.mjs +56 -0
- package/template/default/platform/scripts/dev.mjs +101 -18
- package/template/default/platform/src/generated/modules.server.ts +1 -0
- package/template/default/platform/src/server/database.ts +24 -0
- package/template/default/platform/src/server/lifecycle.ts +325 -0
- package/template/default/platform/src/server/runtime-role.ts +33 -0
- package/template/default/platform/src/server/setup/access.ts +160 -0
- package/template/default/platform/src/server/setup/adapters.ts +554 -0
- package/template/default/platform/src/server/setup/environment.ts +154 -0
- package/template/default/platform/src/server/setup/gate.ts +84 -0
- package/template/default/platform/src/server/setup/index.ts +181 -0
- package/template/default/platform/src/server/setup/modules.ts +119 -0
- package/template/default/platform/src/server/setup/page.ts +548 -0
- package/template/default/platform/src/server/setup/routes.ts +788 -0
- package/template/default/platform/src/server/setup/sanitize.ts +111 -0
- package/template/default/platform/src/server/setup/seed.ts +192 -0
- package/template/default/platform/src/server/setup/token.ts +79 -0
- package/template/default/platform/src/server/worker-tick.ts +193 -0
- package/template/default/platform/src/server/workspace-root.ts +16 -0
- package/template/default/render.yaml +70 -0
- package/template/default/vercel.json +5 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/* The first-run screen runs before any account exists, so there is nothing to
|
|
4
|
+
authenticate against. Access is bound to a token minted at boot, printed to
|
|
5
|
+
stdout and written to the state directory, the way Grafana and Jupyter hand
|
|
6
|
+
an operator their first credential. */
|
|
7
|
+
|
|
8
|
+
export const SETUP_TOKEN_BYTES = 32;
|
|
9
|
+
export const SETUP_SESSION_COOKIE = 'flowdular_setup';
|
|
10
|
+
export const SETUP_CSRF_FIELD = 'setupCsrf';
|
|
11
|
+
|
|
12
|
+
const MAX_TOKEN_FAILURES = 5;
|
|
13
|
+
const LOCKOUT_MS = 5 * 60 * 1000;
|
|
14
|
+
const SESSION_TTL_MS = 30 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
export type SetupAccessVerdict = 'granted' | 'denied' | 'locked';
|
|
17
|
+
|
|
18
|
+
export interface SetupSession {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly csrfToken: string;
|
|
21
|
+
expiresAt: number;
|
|
22
|
+
/* The configuration under review, held only in this process. Secrets never
|
|
23
|
+
reach a cookie, a log, or the disk before the operator confirms. */
|
|
24
|
+
pending: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SetupAccess {
|
|
28
|
+
/** Constant-time token check with a shared lockout after repeated failures. */
|
|
29
|
+
open(presented: string | null): {
|
|
30
|
+
readonly verdict: SetupAccessVerdict;
|
|
31
|
+
readonly session: SetupSession | null;
|
|
32
|
+
readonly retryAfterMs: number;
|
|
33
|
+
};
|
|
34
|
+
/** The live session for a cookie value, or null once it expired or rotated. */
|
|
35
|
+
resume(sessionId: string | null): SetupSession | null;
|
|
36
|
+
/** True only for the CSRF token minted with this session. */
|
|
37
|
+
verifyCsrf(session: SetupSession, presented: string | null): boolean;
|
|
38
|
+
close(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* timingSafeEqual needs equal lengths, and the length of the presented value is
|
|
42
|
+
attacker chosen. Comparing digests keeps the comparison constant time without
|
|
43
|
+
leaking how long the real token is. */
|
|
44
|
+
function constantTimeEquals(left: string, right: string): boolean {
|
|
45
|
+
return timingSafeEqual(
|
|
46
|
+
createHash('sha256').update(left, 'utf8').digest(),
|
|
47
|
+
createHash('sha256').update(right, 'utf8').digest(),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function generateSetupToken(): string {
|
|
52
|
+
return randomBytes(SETUP_TOKEN_BYTES).toString('base64url');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What FD_SETUP_TOKEN_SHA256 holds: the lowercase hex SHA-256 of the token
|
|
56
|
+
* `flowdular deploy start` printed, so the token itself never reaches the host. */
|
|
57
|
+
export function setupTokenDigest(token: string): string {
|
|
58
|
+
return createHash('sha256').update(token, 'utf8').digest('hex');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createSetupAccess(
|
|
62
|
+
expected: string | { readonly sha256: string },
|
|
63
|
+
now: () => number = Date.now,
|
|
64
|
+
): SetupAccess {
|
|
65
|
+
if (typeof expected !== 'string' && !/^[0-9a-f]{64}$/.test(expected.sha256)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'FD_SETUP_TOKEN_SHA256 must be the 64 character lowercase hex SHA-256 of the setup token.',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const expectedDigest = Buffer.from(
|
|
71
|
+
typeof expected === 'string' ? setupTokenDigest(expected) : expected.sha256,
|
|
72
|
+
'hex',
|
|
73
|
+
);
|
|
74
|
+
let failures = 0;
|
|
75
|
+
let lockedUntil = 0;
|
|
76
|
+
/* One operator installs one deployment. Holding a single session makes the
|
|
77
|
+
state O(1) and means a second successful token presentation supersedes the
|
|
78
|
+
first rather than accumulating sessions. */
|
|
79
|
+
let session: SetupSession | null = null;
|
|
80
|
+
|
|
81
|
+
const expire = (): void => {
|
|
82
|
+
if (session && session.expiresAt <= now()) session = null;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
open(presented) {
|
|
87
|
+
const at = now();
|
|
88
|
+
if (at < lockedUntil) {
|
|
89
|
+
return {
|
|
90
|
+
verdict: 'locked',
|
|
91
|
+
session: null,
|
|
92
|
+
retryAfterMs: lockedUntil - at,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
presented === null ||
|
|
97
|
+
presented.length === 0 ||
|
|
98
|
+
!timingSafeEqual(
|
|
99
|
+
createHash('sha256').update(presented, 'utf8').digest(),
|
|
100
|
+
expectedDigest,
|
|
101
|
+
)
|
|
102
|
+
) {
|
|
103
|
+
failures += 1;
|
|
104
|
+
if (failures >= MAX_TOKEN_FAILURES) {
|
|
105
|
+
failures = 0;
|
|
106
|
+
lockedUntil = at + LOCKOUT_MS;
|
|
107
|
+
return { verdict: 'locked', session: null, retryAfterMs: LOCKOUT_MS };
|
|
108
|
+
}
|
|
109
|
+
return { verdict: 'denied', session: null, retryAfterMs: 0 };
|
|
110
|
+
}
|
|
111
|
+
failures = 0;
|
|
112
|
+
session = {
|
|
113
|
+
id: randomBytes(32).toString('base64url'),
|
|
114
|
+
csrfToken: randomBytes(32).toString('base64url'),
|
|
115
|
+
expiresAt: at + SESSION_TTL_MS,
|
|
116
|
+
pending: null,
|
|
117
|
+
};
|
|
118
|
+
return { verdict: 'granted', session, retryAfterMs: 0 };
|
|
119
|
+
},
|
|
120
|
+
resume(sessionId) {
|
|
121
|
+
expire();
|
|
122
|
+
if (!session || sessionId === null || sessionId.length === 0) return null;
|
|
123
|
+
if (!constantTimeEquals(sessionId, session.id)) return null;
|
|
124
|
+
session.expiresAt = now() + SESSION_TTL_MS;
|
|
125
|
+
return session;
|
|
126
|
+
},
|
|
127
|
+
verifyCsrf(current, presented) {
|
|
128
|
+
return (
|
|
129
|
+
presented !== null &&
|
|
130
|
+
presented.length > 0 &&
|
|
131
|
+
constantTimeEquals(presented, current.csrfToken)
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
close() {
|
|
135
|
+
session = null;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function setupSessionCookie(sessionId: string, secure: boolean): string {
|
|
141
|
+
return [
|
|
142
|
+
`${SETUP_SESSION_COOKIE}=${sessionId}`,
|
|
143
|
+
'Path=/',
|
|
144
|
+
'HttpOnly',
|
|
145
|
+
'SameSite=Strict',
|
|
146
|
+
`Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`,
|
|
147
|
+
...(secure ? ['Secure'] : []),
|
|
148
|
+
].join('; ');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function readSetupSessionCookie(header: string | null): string | null {
|
|
152
|
+
if (!header) return null;
|
|
153
|
+
for (const part of header.split(';')) {
|
|
154
|
+
const separator = part.indexOf('=');
|
|
155
|
+
if (separator < 0) continue;
|
|
156
|
+
if (part.slice(0, separator).trim() !== SETUP_SESSION_COOKIE) continue;
|
|
157
|
+
return part.slice(separator + 1).trim() || null;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import { flowdularStateDirectory } from '@flowdular/sdk/kernel/runtime-config';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
createDatabaseAdapterRegistry,
|
|
6
|
+
databaseProviderConfigFromEnvironment,
|
|
7
|
+
DATABASE_ADAPTER_IDS,
|
|
8
|
+
DATABASE_CAPABILITY_IDS,
|
|
9
|
+
DATABASE_DIALECT_IDS,
|
|
10
|
+
type DatabaseAdapter,
|
|
11
|
+
type DatabaseAdapterConnectionInput,
|
|
12
|
+
type DatabaseAdapterDescriptor,
|
|
13
|
+
type DatabaseAdapterId,
|
|
14
|
+
type DatabaseAdapterLease,
|
|
15
|
+
type DatabaseAdapterProbeResult,
|
|
16
|
+
type DatabaseAdapterRegistry,
|
|
17
|
+
type DatabaseAdapterState,
|
|
18
|
+
type DatabaseAdapterValidationIssue,
|
|
19
|
+
type DatabaseRow,
|
|
20
|
+
type DatabaseTransaction,
|
|
21
|
+
} from '@flowdular/sdk/database';
|
|
22
|
+
import {
|
|
23
|
+
createPlatformDatabaseProvider,
|
|
24
|
+
type PlatformDatabaseProvider,
|
|
25
|
+
} from '../database.ts';
|
|
26
|
+
import { classifySetupFailure, SetupProbeError } from './sanitize.ts';
|
|
27
|
+
|
|
28
|
+
export const PGLITE_ADAPTER_ID = 'flowdular.pglite';
|
|
29
|
+
export const POSTGRESQL_ADAPTER_ID = DATABASE_ADAPTER_IDS.postgresql;
|
|
30
|
+
|
|
31
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
32
|
+
const SETUP_NAMESPACE = 'flowdular.setup';
|
|
33
|
+
|
|
34
|
+
/* Mirrors what PostgresDatabaseAdapter advertises. Both first-run options run
|
|
35
|
+
that adapter, so the embedded database enforces the same forced row-level
|
|
36
|
+
security a server does; the readiness check proves it at runtime. */
|
|
37
|
+
const POSTGRES_PROFILE = Object.freeze({
|
|
38
|
+
features: Object.freeze([
|
|
39
|
+
DATABASE_CAPABILITY_IDS.MIGRATION_LOCK,
|
|
40
|
+
DATABASE_CAPABILITY_IDS.RETURNING,
|
|
41
|
+
DATABASE_CAPABILITY_IDS.ROW_LEVEL_SECURITY,
|
|
42
|
+
DATABASE_CAPABILITY_IDS.SCHEMA_INTROSPECTION,
|
|
43
|
+
DATABASE_CAPABILITY_IDS.TENANT_CONTEXT,
|
|
44
|
+
DATABASE_CAPABILITY_IDS.TRANSACTIONAL_DDL,
|
|
45
|
+
DATABASE_CAPABILITY_IDS.TRANSACTIONS,
|
|
46
|
+
]),
|
|
47
|
+
isolationLevels: Object.freeze([
|
|
48
|
+
'read-committed',
|
|
49
|
+
'repeatable-read',
|
|
50
|
+
'serializable',
|
|
51
|
+
] as const),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export interface SetupAdapterOptions {
|
|
55
|
+
readonly workspaceRoot: string;
|
|
56
|
+
readonly production: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A registered adapter plus the two things the platform, not the contract,
|
|
61
|
+
* owns: the environment a deployment needs so this adapter becomes its
|
|
62
|
+
* database, and a provider built from that same environment.
|
|
63
|
+
*/
|
|
64
|
+
export interface PlatformSetupAdapter {
|
|
65
|
+
readonly descriptor: DatabaseAdapterDescriptor;
|
|
66
|
+
environment(input: DatabaseAdapterConnectionInput): Record<string, string>;
|
|
67
|
+
openProvider(input: DatabaseAdapterConnectionInput): PlatformDatabaseProvider;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SetupAdapters {
|
|
71
|
+
readonly registry: DatabaseAdapterRegistry;
|
|
72
|
+
get(adapterId: string): PlatformSetupAdapter | undefined;
|
|
73
|
+
list(): readonly PlatformSetupAdapter[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function text(
|
|
77
|
+
input: DatabaseAdapterConnectionInput,
|
|
78
|
+
key: string,
|
|
79
|
+
): string | undefined {
|
|
80
|
+
const value = input.config[key];
|
|
81
|
+
if (typeof value === 'string') return value.trim() || undefined;
|
|
82
|
+
if (typeof value === 'number') return String(value);
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function secret(
|
|
87
|
+
input: DatabaseAdapterConnectionInput,
|
|
88
|
+
key: string,
|
|
89
|
+
): string | undefined {
|
|
90
|
+
return input.secrets[key] || undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function setupSecretValues(
|
|
94
|
+
input: DatabaseAdapterConnectionInput,
|
|
95
|
+
): readonly string[] {
|
|
96
|
+
return Object.values(input.secrets).filter((value) => value.length > 0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function withTimeout<T>(work: Promise<T>, timeoutMs: number): Promise<T> {
|
|
100
|
+
let timer: NodeJS.Timeout | undefined;
|
|
101
|
+
return Promise.race([
|
|
102
|
+
work,
|
|
103
|
+
new Promise<never>((_resolve, reject) => {
|
|
104
|
+
timer = setTimeout(
|
|
105
|
+
() => reject(new SetupProbeError('TIMED_OUT')),
|
|
106
|
+
timeoutMs,
|
|
107
|
+
);
|
|
108
|
+
timer.unref?.();
|
|
109
|
+
}),
|
|
110
|
+
]).finally(() => clearTimeout(timer));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* A leased runtime handle presented as the adapter the descriptor contract
|
|
114
|
+
returns. Disposing it releases the lease and closes every pool the
|
|
115
|
+
configuration opened, so a probe leaves no connection behind. */
|
|
116
|
+
function leasedAdapter(
|
|
117
|
+
provider: PlatformDatabaseProvider,
|
|
118
|
+
lease: DatabaseAdapterLease,
|
|
119
|
+
): DatabaseAdapter {
|
|
120
|
+
const handle = lease.database;
|
|
121
|
+
let state: DatabaseAdapterState = 'ready';
|
|
122
|
+
return {
|
|
123
|
+
get state() {
|
|
124
|
+
return state;
|
|
125
|
+
},
|
|
126
|
+
adapterId: handle.adapterId,
|
|
127
|
+
dialectId: handle.dialectId,
|
|
128
|
+
capabilities: handle.capabilities,
|
|
129
|
+
schema: handle.schema,
|
|
130
|
+
query<Row extends DatabaseRow = DatabaseRow>(
|
|
131
|
+
statement: Parameters<typeof handle.query>[0],
|
|
132
|
+
options?: Parameters<typeof handle.query>[1],
|
|
133
|
+
) {
|
|
134
|
+
return handle.query<Row>(statement, options);
|
|
135
|
+
},
|
|
136
|
+
execute: (statement, options) => handle.execute(statement, options),
|
|
137
|
+
executeScript: (script, options) => handle.executeScript(script, options),
|
|
138
|
+
transaction<T>(
|
|
139
|
+
operation: (transaction: DatabaseTransaction) => Promise<T>,
|
|
140
|
+
options?: Parameters<typeof handle.transaction>[1],
|
|
141
|
+
) {
|
|
142
|
+
return handle.transaction(operation, options);
|
|
143
|
+
},
|
|
144
|
+
async dispose() {
|
|
145
|
+
if (state !== 'ready') return;
|
|
146
|
+
state = 'disposing';
|
|
147
|
+
try {
|
|
148
|
+
await lease.release();
|
|
149
|
+
await provider.dispose();
|
|
150
|
+
} finally {
|
|
151
|
+
state = 'disposed';
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* Reachability is answered by the provider's own readiness check, which
|
|
158
|
+
already refuses a runtime role holding SUPERUSER or BYPASSRLS, plus a
|
|
159
|
+
background lease because auth.core takes one on its first request. A
|
|
160
|
+
deployment that cannot serve one would boot and then fail. */
|
|
161
|
+
async function probeProvider(
|
|
162
|
+
adapter: PlatformSetupAdapter,
|
|
163
|
+
input: DatabaseAdapterConnectionInput,
|
|
164
|
+
): Promise<DatabaseAdapterProbeResult> {
|
|
165
|
+
const started = Date.now();
|
|
166
|
+
let provider: PlatformDatabaseProvider | undefined;
|
|
167
|
+
try {
|
|
168
|
+
provider = adapter.openProvider(input);
|
|
169
|
+
const opened = provider;
|
|
170
|
+
await withTimeout(
|
|
171
|
+
(async () => {
|
|
172
|
+
await opened.check();
|
|
173
|
+
const background = await opened.acquire({
|
|
174
|
+
namespace: SETUP_NAMESPACE,
|
|
175
|
+
purpose: 'background',
|
|
176
|
+
});
|
|
177
|
+
await background.release();
|
|
178
|
+
})(),
|
|
179
|
+
PROBE_TIMEOUT_MS,
|
|
180
|
+
);
|
|
181
|
+
return { status: 'ready', latencyMs: Date.now() - started };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return {
|
|
184
|
+
status: 'unavailable',
|
|
185
|
+
latencyMs: Date.now() - started,
|
|
186
|
+
message: classifySetupFailure(error, setupSecretValues(input)).message,
|
|
187
|
+
};
|
|
188
|
+
} finally {
|
|
189
|
+
await provider?.dispose().catch(() => undefined);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function connectProvider(
|
|
194
|
+
adapter: PlatformSetupAdapter,
|
|
195
|
+
input: DatabaseAdapterConnectionInput,
|
|
196
|
+
): Promise<DatabaseAdapter> {
|
|
197
|
+
const provider = adapter.openProvider(input);
|
|
198
|
+
try {
|
|
199
|
+
const lease = await provider.acquire({
|
|
200
|
+
namespace: SETUP_NAMESPACE,
|
|
201
|
+
purpose: 'runtime',
|
|
202
|
+
});
|
|
203
|
+
return leasedAdapter(provider, lease);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
await provider.dispose().catch(() => undefined);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function issue(
|
|
211
|
+
field: string,
|
|
212
|
+
code: string,
|
|
213
|
+
message: string,
|
|
214
|
+
): DatabaseAdapterValidationIssue {
|
|
215
|
+
return { field, code, message };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const HOST = /^[A-Za-z0-9._:[\]-]{1,255}$/;
|
|
219
|
+
const IDENTIFIER = /^[A-Za-z0-9_$-]{1,63}$/;
|
|
220
|
+
const TLS_MODES = ['verify-full', 'require', 'disable'] as const;
|
|
221
|
+
|
|
222
|
+
function dsn(
|
|
223
|
+
input: DatabaseAdapterConnectionInput,
|
|
224
|
+
user: string,
|
|
225
|
+
password: string,
|
|
226
|
+
): string {
|
|
227
|
+
const host = text(input, 'host') ?? '';
|
|
228
|
+
const port = text(input, 'port') ?? '5432';
|
|
229
|
+
const database = text(input, 'database') ?? '';
|
|
230
|
+
return `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(
|
|
231
|
+
password,
|
|
232
|
+
)}@${host}:${port}/${encodeURIComponent(database)}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function createPostgresqlAdapter(
|
|
236
|
+
options: SetupAdapterOptions,
|
|
237
|
+
): PlatformSetupAdapter {
|
|
238
|
+
const adapter: PlatformSetupAdapter = {
|
|
239
|
+
descriptor: {
|
|
240
|
+
adapterId: POSTGRESQL_ADAPTER_ID,
|
|
241
|
+
dialectId: DATABASE_DIALECT_IDS.postgresql,
|
|
242
|
+
label: 'PostgreSQL server',
|
|
243
|
+
description:
|
|
244
|
+
'A PostgreSQL server you already run. The deployment connects with three roles: one that owns the schema, one tenant-scoped role that serves requests, and one read-only role for the few cross-tenant lookups.',
|
|
245
|
+
capabilities: POSTGRES_PROFILE,
|
|
246
|
+
configurationSchema: {
|
|
247
|
+
version: 1,
|
|
248
|
+
fields: [
|
|
249
|
+
{
|
|
250
|
+
key: 'host',
|
|
251
|
+
label: 'Host',
|
|
252
|
+
description: 'Host name or address of the PostgreSQL server.',
|
|
253
|
+
kind: 'text',
|
|
254
|
+
required: true,
|
|
255
|
+
secret: false,
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
key: 'port',
|
|
259
|
+
label: 'Port',
|
|
260
|
+
description: 'Port the server listens on.',
|
|
261
|
+
kind: 'integer',
|
|
262
|
+
required: true,
|
|
263
|
+
secret: false,
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
key: 'database',
|
|
267
|
+
label: 'Database',
|
|
268
|
+
description: 'An existing, empty database this deployment owns.',
|
|
269
|
+
kind: 'text',
|
|
270
|
+
required: true,
|
|
271
|
+
secret: false,
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
key: 'migrator-user',
|
|
275
|
+
label: 'Migration role',
|
|
276
|
+
description:
|
|
277
|
+
'Owns the schema. Used only while migrations run, never to serve a request.',
|
|
278
|
+
kind: 'text',
|
|
279
|
+
required: true,
|
|
280
|
+
secret: false,
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
key: 'migrator-password',
|
|
284
|
+
label: 'Migration role password',
|
|
285
|
+
description: 'Password for the migration role.',
|
|
286
|
+
kind: 'text',
|
|
287
|
+
required: true,
|
|
288
|
+
secret: true,
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
key: 'runtime-user',
|
|
292
|
+
label: 'Runtime role',
|
|
293
|
+
description:
|
|
294
|
+
'Serves every request. It must hold neither SUPERUSER nor BYPASSRLS, because tenant isolation depends on that.',
|
|
295
|
+
kind: 'text',
|
|
296
|
+
required: true,
|
|
297
|
+
secret: false,
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
key: 'runtime-password',
|
|
301
|
+
label: 'Runtime role password',
|
|
302
|
+
description: 'Password for the runtime role.',
|
|
303
|
+
kind: 'text',
|
|
304
|
+
required: true,
|
|
305
|
+
secret: true,
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
key: 'background-user',
|
|
309
|
+
label: 'Background role',
|
|
310
|
+
description:
|
|
311
|
+
'Reads the routing columns a scheduler poll needs across tenants. It writes nothing.',
|
|
312
|
+
kind: 'text',
|
|
313
|
+
required: true,
|
|
314
|
+
secret: false,
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
key: 'background-password',
|
|
318
|
+
label: 'Background role password',
|
|
319
|
+
description: 'Password for the background role.',
|
|
320
|
+
kind: 'text',
|
|
321
|
+
required: true,
|
|
322
|
+
secret: true,
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
key: 'tls',
|
|
326
|
+
label: 'TLS',
|
|
327
|
+
description: options.production
|
|
328
|
+
? 'A production deployment verifies the server certificate in full.'
|
|
329
|
+
: 'How this deployment verifies the server certificate.',
|
|
330
|
+
kind: 'select',
|
|
331
|
+
required: true,
|
|
332
|
+
secret: false,
|
|
333
|
+
options: (options.production
|
|
334
|
+
? (['verify-full'] as const)
|
|
335
|
+
: TLS_MODES
|
|
336
|
+
).map((mode) => ({ label: mode, value: mode })),
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
key: 'tls-authority-file',
|
|
340
|
+
label: 'Certificate authority file',
|
|
341
|
+
description:
|
|
342
|
+
'Path to a PEM certificate authority, when the server presents a certificate this machine does not already trust.',
|
|
343
|
+
kind: 'text',
|
|
344
|
+
required: false,
|
|
345
|
+
secret: false,
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
},
|
|
349
|
+
validate(input) {
|
|
350
|
+
const issues: DatabaseAdapterValidationIssue[] = [];
|
|
351
|
+
const host = text(input, 'host');
|
|
352
|
+
if (!host || !HOST.test(host)) {
|
|
353
|
+
issues.push(
|
|
354
|
+
issue('host', 'INVALID', 'Enter a host name or an address.'),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const port = Number(text(input, 'port'));
|
|
358
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
|
|
359
|
+
issues.push(
|
|
360
|
+
issue('port', 'INVALID', 'Enter a port between 1 and 65535.'),
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
const database = text(input, 'database');
|
|
364
|
+
if (!database || !IDENTIFIER.test(database)) {
|
|
365
|
+
issues.push(
|
|
366
|
+
issue('database', 'INVALID', 'Enter an existing database name.'),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
for (const role of ['migrator', 'runtime', 'background'] as const) {
|
|
370
|
+
const user = text(input, `${role}-user`);
|
|
371
|
+
if (!user || !IDENTIFIER.test(user)) {
|
|
372
|
+
issues.push(
|
|
373
|
+
issue(`${role}-user`, 'INVALID', 'Enter the role name.'),
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
if (!secret(input, `${role}-password`)) {
|
|
377
|
+
issues.push(
|
|
378
|
+
issue(
|
|
379
|
+
`${role}-password`,
|
|
380
|
+
'REQUIRED',
|
|
381
|
+
'Enter the password for this role.',
|
|
382
|
+
),
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const migrator = text(input, 'migrator-user');
|
|
387
|
+
const runtime = text(input, 'runtime-user');
|
|
388
|
+
if (migrator && runtime && migrator === runtime) {
|
|
389
|
+
issues.push(
|
|
390
|
+
issue(
|
|
391
|
+
'runtime-user',
|
|
392
|
+
'INVALID',
|
|
393
|
+
'The runtime role must differ from the migration role, so a request can never run schema operations.',
|
|
394
|
+
),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
const tls = text(input, 'tls');
|
|
398
|
+
if (!tls || !TLS_MODES.includes(tls as (typeof TLS_MODES)[number])) {
|
|
399
|
+
issues.push(issue('tls', 'INVALID', 'Choose how TLS is verified.'));
|
|
400
|
+
} else if (options.production && tls !== 'verify-full') {
|
|
401
|
+
issues.push(
|
|
402
|
+
issue(
|
|
403
|
+
'tls',
|
|
404
|
+
'INVALID',
|
|
405
|
+
'A production deployment requires full certificate verification.',
|
|
406
|
+
),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return issues;
|
|
410
|
+
},
|
|
411
|
+
probe: (input) => probeProvider(adapter, input),
|
|
412
|
+
async provision(_input, authorization) {
|
|
413
|
+
if (authorization.intent !== 'confirmed-first-run') {
|
|
414
|
+
throw new Error('Provisioning requires a confirmed first run.');
|
|
415
|
+
}
|
|
416
|
+
/* The server, the database, and the three roles belong to the
|
|
417
|
+
operator. Creating them would need a superuser credential this
|
|
418
|
+
screen deliberately never asks for. */
|
|
419
|
+
},
|
|
420
|
+
connect: (input) => connectProvider(adapter, input),
|
|
421
|
+
},
|
|
422
|
+
environment(input) {
|
|
423
|
+
const authority = text(input, 'tls-authority-file');
|
|
424
|
+
return {
|
|
425
|
+
FD_DATABASE_ADAPTER: 'postgresql',
|
|
426
|
+
FD_DATABASE_URL: dsn(
|
|
427
|
+
input,
|
|
428
|
+
text(input, 'runtime-user') ?? '',
|
|
429
|
+
secret(input, 'runtime-password') ?? '',
|
|
430
|
+
),
|
|
431
|
+
FD_DATABASE_MIGRATOR_URL: dsn(
|
|
432
|
+
input,
|
|
433
|
+
text(input, 'migrator-user') ?? '',
|
|
434
|
+
secret(input, 'migrator-password') ?? '',
|
|
435
|
+
),
|
|
436
|
+
FD_DATABASE_BACKGROUND_URL: dsn(
|
|
437
|
+
input,
|
|
438
|
+
text(input, 'background-user') ?? '',
|
|
439
|
+
secret(input, 'background-password') ?? '',
|
|
440
|
+
),
|
|
441
|
+
FD_DATABASE_TLS: text(input, 'tls') ?? 'verify-full',
|
|
442
|
+
...(authority ? { FD_DATABASE_TLS_CA_FILE: authority } : {}),
|
|
443
|
+
};
|
|
444
|
+
},
|
|
445
|
+
openProvider(input) {
|
|
446
|
+
return createPlatformDatabaseProvider(
|
|
447
|
+
databaseProviderConfigFromEnvironment(
|
|
448
|
+
{
|
|
449
|
+
NODE_ENV: options.production ? 'production' : 'development',
|
|
450
|
+
FD_DATABASE_CONNECT_TIMEOUT_MS: String(PROBE_TIMEOUT_MS),
|
|
451
|
+
...adapter.environment(input),
|
|
452
|
+
},
|
|
453
|
+
options.workspaceRoot,
|
|
454
|
+
),
|
|
455
|
+
);
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
return adapter;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function createPgliteAdapter(
|
|
462
|
+
options: SetupAdapterOptions,
|
|
463
|
+
): PlatformSetupAdapter {
|
|
464
|
+
const defaultDirectory = resolve(
|
|
465
|
+
flowdularStateDirectory(options.workspaceRoot),
|
|
466
|
+
'data',
|
|
467
|
+
'pglite',
|
|
468
|
+
);
|
|
469
|
+
const directoryOf = (input: DatabaseAdapterConnectionInput): string =>
|
|
470
|
+
resolve(
|
|
471
|
+
options.workspaceRoot,
|
|
472
|
+
text(input, 'data-directory') ?? defaultDirectory,
|
|
473
|
+
);
|
|
474
|
+
const adapter: PlatformSetupAdapter = {
|
|
475
|
+
descriptor: {
|
|
476
|
+
adapterId: PGLITE_ADAPTER_ID,
|
|
477
|
+
dialectId: DATABASE_DIALECT_IDS.postgresql,
|
|
478
|
+
label: 'Embedded PostgreSQL',
|
|
479
|
+
description:
|
|
480
|
+
'PostgreSQL running inside this process, storing its data in a directory on this machine. Nothing to install and no credentials to manage; it enforces the same forced row-level security a server does.',
|
|
481
|
+
capabilities: POSTGRES_PROFILE,
|
|
482
|
+
configurationSchema: {
|
|
483
|
+
version: 1,
|
|
484
|
+
fields: [
|
|
485
|
+
{
|
|
486
|
+
key: 'data-directory',
|
|
487
|
+
label: 'Data directory',
|
|
488
|
+
description:
|
|
489
|
+
'Where the database files live. Leave it empty to use the default below.',
|
|
490
|
+
kind: 'text',
|
|
491
|
+
required: false,
|
|
492
|
+
secret: false,
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
},
|
|
496
|
+
validate(input) {
|
|
497
|
+
const directory = text(input, 'data-directory');
|
|
498
|
+
if (directory && /[\0]/.test(directory)) {
|
|
499
|
+
return [
|
|
500
|
+
issue('data-directory', 'INVALID', 'Enter a directory path.'),
|
|
501
|
+
];
|
|
502
|
+
}
|
|
503
|
+
return [];
|
|
504
|
+
},
|
|
505
|
+
probe: (input) => probeProvider(adapter, input),
|
|
506
|
+
async provision(input, authorization) {
|
|
507
|
+
if (authorization.intent !== 'confirmed-first-run') {
|
|
508
|
+
throw new Error('Provisioning requires a confirmed first run.');
|
|
509
|
+
}
|
|
510
|
+
/* The only external state this adapter owns is its data directory,
|
|
511
|
+
and the database files inside it must not be world readable. */
|
|
512
|
+
mkdirSync(directoryOf(input), { recursive: true, mode: 0o700 });
|
|
513
|
+
},
|
|
514
|
+
connect: (input) => connectProvider(adapter, input),
|
|
515
|
+
},
|
|
516
|
+
environment: (input) => ({
|
|
517
|
+
FD_DATABASE_ADAPTER: 'pglite',
|
|
518
|
+
FD_DATABASE_PGLITE_DIRECTORY: directoryOf(input),
|
|
519
|
+
}),
|
|
520
|
+
openProvider(input) {
|
|
521
|
+
return createPlatformDatabaseProvider(
|
|
522
|
+
databaseProviderConfigFromEnvironment(
|
|
523
|
+
{ NODE_ENV: 'development', ...adapter.environment(input) },
|
|
524
|
+
options.workspaceRoot,
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
return adapter;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The adapters this deployment can be pointed at on a first run. The embedded
|
|
534
|
+
* database is absent in production: a deployment states its real database
|
|
535
|
+
* instead of shipping one inside the process.
|
|
536
|
+
*/
|
|
537
|
+
export function createSetupAdapters(
|
|
538
|
+
options: SetupAdapterOptions,
|
|
539
|
+
): SetupAdapters {
|
|
540
|
+
const registry = createDatabaseAdapterRegistry();
|
|
541
|
+
const adapters = new Map<DatabaseAdapterId, PlatformSetupAdapter>();
|
|
542
|
+
const register = (adapter: PlatformSetupAdapter): void => {
|
|
543
|
+
registry.register(adapter.descriptor);
|
|
544
|
+
adapters.set(adapter.descriptor.adapterId, adapter);
|
|
545
|
+
};
|
|
546
|
+
if (!options.production) register(createPgliteAdapter(options));
|
|
547
|
+
register(createPostgresqlAdapter(options));
|
|
548
|
+
registry.seal();
|
|
549
|
+
return {
|
|
550
|
+
registry,
|
|
551
|
+
get: (adapterId) => adapters.get(adapterId),
|
|
552
|
+
list: () => [...adapters.values()],
|
|
553
|
+
};
|
|
554
|
+
}
|