create-flowdular 0.5.1 → 0.6.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/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/test-hardening/SKILL.md +2 -2
- package/agent-template/.agents/skills/workflow-development/SKILL.md +95 -9
- 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 +7 -5
- 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/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/test-hardening/SKILL.md +2 -2
- package/agent-template/.claude/skills/workflow-development/SKILL.md +95 -9
- package/agent-template/docs/adr/0007-module-owned-agents.md +35 -1
- package/agent-template/docs/agent-contract.md +2 -2
- package/agent-template/docs/cli.md +24 -3
- package/agent-template/docs/configuration.md +32 -5
- package/agent-template/docs/database-adapters.md +20 -20
- package/agent-template/docs/design-system.md +1 -1
- package/agent-template/docs/getting-started.md +25 -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 +3 -1
- 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 +3 -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 +66 -0
- package/template/default/infra/docker/Dockerfile +24 -10
- package/template/default/infra/docker/app-entrypoint.mjs +5 -0
- package/template/default/infra/docker/compose.yaml +105 -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/vercel/README.md +262 -0
- package/template/default/infra/vercel/build.mjs +214 -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 -2
- package/template/default/platform/octane.config.ts +252 -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 +38 -0
- 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/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 +123 -0
- package/template/default/platform/src/server/setup/page.ts +497 -0
- package/template/default/platform/src/server/setup/routes.ts +787 -0
- package/template/default/platform/src/server/setup/sanitize.ts +111 -0
- package/template/default/platform/src/server/setup/seed.ts +145 -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,33 @@
|
|
|
1
|
+
import type { ModuleServerComposition } from '@flowdular/sdk/server';
|
|
2
|
+
|
|
3
|
+
/* combined serves HTTP and runs every module worker in one process. web serves
|
|
4
|
+
HTTP only: its compositions start, but no module worker loop, poller or
|
|
5
|
+
wake-driven claim runs there, so a host that freezes idle instances never
|
|
6
|
+
strands work. The loops a web process still runs are auth.core's expired
|
|
7
|
+
session sweep (auth.core.sessions) and settings log sweep
|
|
8
|
+
(auth.core.settings-log), which start with the auth service in every role;
|
|
9
|
+
freezing them only delays deleting rows no lookup needs and writing a
|
|
10
|
+
platform settings event its saving process deferred. tick runs the module
|
|
11
|
+
workers only inside an authenticated tick request, which drains them before
|
|
12
|
+
it answers. */
|
|
13
|
+
export type PlatformRuntimeRole = 'combined' | 'web' | 'tick';
|
|
14
|
+
|
|
15
|
+
export function platformRuntimeRole(
|
|
16
|
+
environment: NodeJS.ProcessEnv,
|
|
17
|
+
): PlatformRuntimeRole {
|
|
18
|
+
const role = environment.FD_RUNTIME_ROLE?.trim() || 'combined';
|
|
19
|
+
if (role !== 'combined' && role !== 'web' && role !== 'tick') {
|
|
20
|
+
throw new Error('FD_RUNTIME_ROLE must be "combined", "web" or "tick".');
|
|
21
|
+
}
|
|
22
|
+
return role;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* Awaited in composition order, so a failed worker start fails the boot before
|
|
26
|
+
the process reports itself ready. */
|
|
27
|
+
export async function startModuleWorkers(
|
|
28
|
+
compositions: readonly ModuleServerComposition[],
|
|
29
|
+
role: PlatformRuntimeRole,
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
if (role !== 'combined') return;
|
|
32
|
+
for (const composition of compositions) await composition.startWorker?.();
|
|
33
|
+
}
|
|
@@ -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
|
+
}
|