@ismaelsoilet/synapsejs 1.1.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/LICENSE +21 -0
- package/README.md +206 -0
- package/bin/synapse.ts +919 -0
- package/package.json +70 -0
- package/src/client/components.tsx +508 -0
- package/src/client/context.tsx +62 -0
- package/src/client/hooks.ts +152 -0
- package/src/client/i18n.ts +40 -0
- package/src/client.ts +66 -0
- package/src/compiler/agent-diagnostic-json.ts +113 -0
- package/src/compiler/ast-daemon-compressor.ts +190 -0
- package/src/compiler/client-bundler.ts +356 -0
- package/src/compiler/context-bench.ts +191 -0
- package/src/compiler/contract.ts +260 -0
- package/src/compiler/db-schema-generator.ts +306 -0
- package/src/compiler/fields-parser.ts +267 -0
- package/src/compiler/impact-analyzer.ts +279 -0
- package/src/compiler/migration-runner.ts +555 -0
- package/src/compiler/oracle-runner.ts +293 -0
- package/src/compiler/scaffolder.ts +528 -0
- package/src/compiler/schema-dag.ts +137 -0
- package/src/compiler/schema-drift.ts +184 -0
- package/src/compiler/slice-discovery.ts +154 -0
- package/src/compiler/slice-splitter.ts +838 -0
- package/src/compiler/slice-templates.ts +1527 -0
- package/src/compiler/standalone-builder.ts +117 -0
- package/src/core/action-context.ts +152 -0
- package/src/core/config.ts +77 -0
- package/src/core/database-client.ts +246 -0
- package/src/core/database-factory.ts +46 -0
- package/src/core/image-optimizer.ts +83 -0
- package/src/core/index.ts +14 -0
- package/src/core/jobs.ts +53 -0
- package/src/core/machine-types.ts +75 -0
- package/src/core/oracle.ts +20 -0
- package/src/core/postgres-client.ts +164 -0
- package/src/core/postgres-queue.ts +288 -0
- package/src/core/query-builder.ts +343 -0
- package/src/core/rpc-client.ts +70 -0
- package/src/core/session-context.ts +87 -0
- package/src/core/session-cookie.ts +81 -0
- package/src/core/session-token.ts +170 -0
- package/src/core/sqlite-client.ts +201 -0
- package/src/core/storage.ts +317 -0
- package/src/index.ts +133 -0
- package/src/mcp/server.ts +553 -0
- package/src/runtime/client-entry.ts +65 -0
- package/src/runtime/discovery-rules.ts +63 -0
- package/src/runtime/event-hub.ts +97 -0
- package/src/runtime/postgres-event-hub.ts +148 -0
- package/src/runtime/queue-engine.ts +255 -0
- package/src/runtime/rate-limiter.ts +152 -0
- package/src/runtime/server.ts +1550 -0
- package/src/runtime/uploads.ts +192 -0
- package/templates/starter/.dockerignore +7 -0
- package/templates/starter/Dockerfile +40 -0
- package/templates/starter/README.md +40 -0
- package/templates/starter/gitignore +6 -0
- package/templates/starter/package.json +34 -0
- package/templates/starter/src/slices/welcome/hello-world.slice.tsx +172 -0
- package/templates/starter/tsconfig.json +21 -0
|
@@ -0,0 +1,1527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Slice Templates
|
|
3
|
+
*
|
|
4
|
+
* The shapes an agent should never have to invent: list with pagination and a
|
|
5
|
+
* filter, partial update with a column allowlist, and delete with an existence
|
|
6
|
+
* guard. `create` lives in the scaffolder and stays the default.
|
|
7
|
+
*
|
|
8
|
+
* Every template is proven by the splitter's own gates in the tests: the emitted
|
|
9
|
+
* slice must compile and its client bundle must leak nothing. That is the reason
|
|
10
|
+
* the templates can be trusted as a starting point rather than only as prose.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { generateTableColumnsCode, parseFields } from './fields-parser';
|
|
14
|
+
|
|
15
|
+
export type SliceTemplate = 'create' | 'list' | 'update' | 'delete' | 'login' | 'oauth-github' | 'auth-2fa';
|
|
16
|
+
export const SLICE_TEMPLATES: SliceTemplate[] = [
|
|
17
|
+
'create',
|
|
18
|
+
'list',
|
|
19
|
+
'update',
|
|
20
|
+
'delete',
|
|
21
|
+
'login',
|
|
22
|
+
'oauth-github',
|
|
23
|
+
'auth-2fa'
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/** Paginação das listas geradas: o agente não escolhe esses números por conta. */
|
|
27
|
+
export const DEFAULT_LIST_LIMIT = 20;
|
|
28
|
+
export const MAX_LIST_LIMIT = 100;
|
|
29
|
+
|
|
30
|
+
export interface TemplateNames {
|
|
31
|
+
pascal: string;
|
|
32
|
+
camel: string;
|
|
33
|
+
table: string;
|
|
34
|
+
schemaName: string;
|
|
35
|
+
inputName: string;
|
|
36
|
+
outputName: string;
|
|
37
|
+
actionName: string;
|
|
38
|
+
componentName: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function templateNames(domain: string, sliceName: string): TemplateNames {
|
|
42
|
+
const pascal = sliceName
|
|
43
|
+
.split(/[-_]/)
|
|
44
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
45
|
+
.join('');
|
|
46
|
+
const camel = pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
pascal,
|
|
50
|
+
camel,
|
|
51
|
+
table: domain.replace(/[^a-zA-Z0-9_]/g, '_'),
|
|
52
|
+
schemaName: `${pascal}InputSchema`,
|
|
53
|
+
inputName: `${pascal}Input`,
|
|
54
|
+
outputName: `${pascal}Output`,
|
|
55
|
+
actionName: `${camel}Action`,
|
|
56
|
+
componentName: `${pascal}Trigger`
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function imports(needsLoader: boolean): string {
|
|
61
|
+
return `import React, { useState } from 'react';
|
|
62
|
+
import { Type, Static } from '@sinclair/typebox';
|
|
63
|
+
import { Value } from '@sinclair/typebox/value';
|
|
64
|
+
import {
|
|
65
|
+
type DatabaseClient,
|
|
66
|
+
Err,
|
|
67
|
+
MockDatabaseClient,
|
|
68
|
+
Ok,
|
|
69
|
+
type Result,
|
|
70
|
+
type SessionContext,
|
|
71
|
+
${needsLoader ? 'type SliceLoaderContext,\n ' : ''}createSession,
|
|
72
|
+
requireAuth
|
|
73
|
+
} from 'synapsejs';`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fieldsListTemplate(names: TemplateNames, fieldsSpec: string): string {
|
|
77
|
+
const fields = parseFields(fieldsSpec);
|
|
78
|
+
const listRow = `${names.pascal}Row`;
|
|
79
|
+
const tableColumns = generateTableColumnsCode(fields);
|
|
80
|
+
|
|
81
|
+
return `${imports(true)}
|
|
82
|
+
import { DataTable, Card, Pagination } from 'synapsejs/client';
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// 1. CONTRATO DE ENTRADA (TypeBox) — filtro e paginação tipados
|
|
86
|
+
// ============================================================================
|
|
87
|
+
export const ${names.schemaName} = Type.Object({
|
|
88
|
+
query: Type.Optional(Type.String({ maxLength: 60 })),
|
|
89
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
90
|
+
offset: Type.Optional(Type.Integer({ minimum: 0 }))
|
|
91
|
+
});
|
|
92
|
+
export type ${names.inputName} = Static<typeof ${names.schemaName}>;
|
|
93
|
+
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// 2. MODELAGEM DE DOMÍNIO (Result<T, E>)
|
|
96
|
+
// ============================================================================
|
|
97
|
+
export interface ${listRow} {
|
|
98
|
+
id: string;
|
|
99
|
+
${fields.map((f) => ` ${f.name}: ${f.type === 'number' || f.type === 'integer' ? 'number' : f.type === 'boolean' ? 'boolean' : 'string'};`).join('\n')}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type ${names.outputName} = Result<
|
|
103
|
+
{ rows: ${listRow}[]; total: number; limit: number; offset: number },
|
|
104
|
+
'INVALID_SCHEMA' | 'NO_DATABASE' | 'UNAUTHORIZED'
|
|
105
|
+
>;
|
|
106
|
+
|
|
107
|
+
const DEFAULT_LIMIT = ${DEFAULT_LIST_LIMIT};
|
|
108
|
+
const MAX_LIMIT = ${MAX_LIST_LIMIT};
|
|
109
|
+
|
|
110
|
+
// ============================================================================
|
|
111
|
+
// 3. SERVER ACTION — busca utilizando Query Builder
|
|
112
|
+
// ============================================================================
|
|
113
|
+
export async function ${names.actionName}(
|
|
114
|
+
payload: unknown,
|
|
115
|
+
db?: DatabaseClient,
|
|
116
|
+
session?: SessionContext
|
|
117
|
+
): Promise<${names.outputName}> {
|
|
118
|
+
if (!session?.isAuthenticated) {
|
|
119
|
+
return Err('UNAUTHORIZED');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!db) {
|
|
123
|
+
return Err('NO_DATABASE');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const input = (payload ?? {}) as ${names.inputName};
|
|
127
|
+
if (!Value.Check(${names.schemaName}, input)) {
|
|
128
|
+
return Err('INVALID_SCHEMA');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const limit = Math.min(input.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
|
|
132
|
+
const offset = input.offset ?? 0;
|
|
133
|
+
const term = input.query ?? '';
|
|
134
|
+
|
|
135
|
+
const where = term ? { ${fields[0]?.name || 'id'}: { like: \`%\${term}%\` } } : {};
|
|
136
|
+
const rows = await db.findMany<${listRow}>('${names.table}', {
|
|
137
|
+
where,
|
|
138
|
+
limit,
|
|
139
|
+
offset
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return Ok({ rows, total: rows.length, limit, offset });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ============================================================================
|
|
146
|
+
// 4. LOADER — a lista renderiza no servidor com dados reais
|
|
147
|
+
// ============================================================================
|
|
148
|
+
export async function ${names.pascal}Loader(
|
|
149
|
+
context: SliceLoaderContext
|
|
150
|
+
): Promise<{ rows: ${listRow}[]; total: number; loadError?: string; query?: string }> {
|
|
151
|
+
const query = (context.params.query ?? '').slice(0, 60);
|
|
152
|
+
const limit = Number(context.params.limit ?? DEFAULT_LIMIT);
|
|
153
|
+
const offset = Number(context.params.offset ?? 0);
|
|
154
|
+
|
|
155
|
+
const result = await ${names.actionName}({ query, limit, offset }, context.db, context.session);
|
|
156
|
+
|
|
157
|
+
return result.ok
|
|
158
|
+
? { rows: result.value.rows, total: result.value.total, query }
|
|
159
|
+
: { rows: [], total: 0, query, loadError: result.error };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// 5. UI REACT com <DataTable> e <Card>
|
|
164
|
+
// ============================================================================
|
|
165
|
+
export interface ${names.pascal}ViewProps {
|
|
166
|
+
rows?: ${listRow}[];
|
|
167
|
+
total?: number;
|
|
168
|
+
loadError?: string;
|
|
169
|
+
query?: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const TABLE_COLUMNS = ${tableColumns};
|
|
173
|
+
|
|
174
|
+
export function ${names.componentName}({ rows = [], total = 0, loadError, query = '' }: ${names.pascal}ViewProps) {
|
|
175
|
+
if (loadError) {
|
|
176
|
+
return (
|
|
177
|
+
<div className="p-4 rounded-lg border border-rose-800 bg-rose-950/50 text-rose-200 font-mono text-sm">
|
|
178
|
+
Não foi possível listar: {loadError}
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<Card title="Listagem de ${names.pascal}">
|
|
185
|
+
<DataTable data={rows} columns={TABLE_COLUMNS} />
|
|
186
|
+
<div className="mt-4">
|
|
187
|
+
<Pagination total={total} limit={DEFAULT_LIMIT} offset={0} />
|
|
188
|
+
</div>
|
|
189
|
+
</Card>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ============================================================================
|
|
194
|
+
// 6. ORÁCULO DE INVARIANTES
|
|
195
|
+
// ============================================================================
|
|
196
|
+
export const sliceTests = {
|
|
197
|
+
description: 'Invariantes de listagem de ${names.table}',
|
|
198
|
+
cases: [
|
|
199
|
+
{
|
|
200
|
+
name: 'chamada anônima retorna UNAUTHORIZED',
|
|
201
|
+
run: async () => {
|
|
202
|
+
const result = await ${names.actionName}({}, new MockDatabaseClient());
|
|
203
|
+
if (result.ok || result.error !== 'UNAUTHORIZED') {
|
|
204
|
+
throw new Error('esperava UNAUTHORIZED');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: 'sem conexão de banco retorna NO_DATABASE',
|
|
210
|
+
run: async () => {
|
|
211
|
+
const session = createSession({ userId: 'user-1', roles: ['reader'] });
|
|
212
|
+
const result = await ${names.actionName}({}, undefined, session);
|
|
213
|
+
if (result.ok || result.error !== 'NO_DATABASE') {
|
|
214
|
+
throw new Error('esperava NO_DATABASE');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
]
|
|
219
|
+
};
|
|
220
|
+
`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** `list`: filter, pagination and a loader that renders real rows on the server. */
|
|
224
|
+
function listTemplate(names: TemplateNames, fieldsSpec?: string): string {
|
|
225
|
+
if (fieldsSpec?.trim()) {
|
|
226
|
+
return fieldsListTemplate(names, fieldsSpec);
|
|
227
|
+
}
|
|
228
|
+
const listRow = `${names.pascal}Row`;
|
|
229
|
+
|
|
230
|
+
return `${imports(true)}
|
|
231
|
+
|
|
232
|
+
// ============================================================================
|
|
233
|
+
// 1. CONTRATO DE ENTRADA (TypeBox) — filtro e paginação tipados
|
|
234
|
+
// ============================================================================
|
|
235
|
+
export const ${names.schemaName} = Type.Object({
|
|
236
|
+
query: Type.Optional(Type.String({ maxLength: 60 })),
|
|
237
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
238
|
+
offset: Type.Optional(Type.Integer({ minimum: 0 }))
|
|
239
|
+
});
|
|
240
|
+
export type ${names.inputName} = Static<typeof ${names.schemaName}>;
|
|
241
|
+
|
|
242
|
+
// A tabela pertence a outra fatia desta mesma feature.
|
|
243
|
+
|
|
244
|
+
// ============================================================================
|
|
245
|
+
// 2. MODELAGEM DE DOMÍNIO (Result<T, E>)
|
|
246
|
+
// ============================================================================
|
|
247
|
+
export interface ${listRow} {
|
|
248
|
+
id: string;
|
|
249
|
+
name: string;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export type ${names.outputName} = Result<
|
|
253
|
+
{ rows: ${listRow}[]; total: number; limit: number; offset: number },
|
|
254
|
+
'INVALID_SCHEMA' | 'NO_DATABASE' | 'UNAUTHORIZED'
|
|
255
|
+
>;
|
|
256
|
+
|
|
257
|
+
const DEFAULT_LIMIT = ${DEFAULT_LIST_LIMIT};
|
|
258
|
+
const MAX_LIMIT = ${MAX_LIST_LIMIT};
|
|
259
|
+
|
|
260
|
+
// ============================================================================
|
|
261
|
+
// 3. SERVER ACTION — busca parametrizada, nunca concatenada
|
|
262
|
+
// ============================================================================
|
|
263
|
+
export async function ${names.actionName}(
|
|
264
|
+
payload: unknown,
|
|
265
|
+
db?: DatabaseClient,
|
|
266
|
+
session?: SessionContext
|
|
267
|
+
): Promise<${names.outputName}> {
|
|
268
|
+
if (!session?.isAuthenticated) {
|
|
269
|
+
return Err('UNAUTHORIZED');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!db) {
|
|
273
|
+
return Err('NO_DATABASE');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const input = (payload ?? {}) as ${names.inputName};
|
|
277
|
+
if (!Value.Check(${names.schemaName}, input)) {
|
|
278
|
+
return Err('INVALID_SCHEMA');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const limit = Math.min(input.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
|
|
282
|
+
const offset = input.offset ?? 0;
|
|
283
|
+
const term = input.query ?? '';
|
|
284
|
+
|
|
285
|
+
const rows = await db.query<${listRow}>(
|
|
286
|
+
\`SELECT id, name FROM ${names.table}
|
|
287
|
+
WHERE ($1 = '' OR name LIKE '%' || $1 || '%')
|
|
288
|
+
ORDER BY name
|
|
289
|
+
LIMIT $2 OFFSET $3\`,
|
|
290
|
+
[term, limit, offset]
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const counted = await db.query<{ total: number }>(
|
|
294
|
+
\`SELECT COUNT(*) AS total FROM ${names.table} WHERE ($1 = '' OR name LIKE '%' || $1 || '%')\`,
|
|
295
|
+
[term]
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
return Ok({ rows, total: counted[0]?.total ?? 0, limit, offset });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ============================================================================
|
|
302
|
+
// 4. LOADER — a lista renderiza no servidor com dados reais
|
|
303
|
+
// ============================================================================
|
|
304
|
+
export async function ${names.pascal}Loader(
|
|
305
|
+
context: SliceLoaderContext
|
|
306
|
+
): Promise<{ rows: ${listRow}[]; total: number; loadError?: string; query?: string }> {
|
|
307
|
+
const query = (context.params.query ?? '').slice(0, 60);
|
|
308
|
+
const limit = Number(context.params.limit ?? DEFAULT_LIMIT);
|
|
309
|
+
const offset = Number(context.params.offset ?? 0);
|
|
310
|
+
|
|
311
|
+
const result = await ${names.actionName}({ query, limit, offset }, context.db, context.session);
|
|
312
|
+
|
|
313
|
+
return result.ok
|
|
314
|
+
? { rows: result.value.rows, total: result.value.total, query }
|
|
315
|
+
: { rows: [], total: 0, query, loadError: result.error };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ============================================================================
|
|
319
|
+
// 5. UI REACT
|
|
320
|
+
// ============================================================================
|
|
321
|
+
export interface ${names.pascal}ViewProps {
|
|
322
|
+
rows?: ${listRow}[];
|
|
323
|
+
total?: number;
|
|
324
|
+
loadError?: string;
|
|
325
|
+
query?: string;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function ${names.componentName}({ rows = [], total = 0, loadError, query = '' }: ${names.pascal}ViewProps) {
|
|
329
|
+
const [term, setTerm] = useState(query);
|
|
330
|
+
|
|
331
|
+
if (loadError) {
|
|
332
|
+
return (
|
|
333
|
+
<div className="p-4 rounded-lg border border-rose-800 bg-rose-950/50 text-rose-200 font-mono text-sm">
|
|
334
|
+
Não foi possível listar: {loadError}
|
|
335
|
+
</div>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<div className="flex flex-col gap-4">
|
|
341
|
+
<form method="GET" className="flex gap-2">
|
|
342
|
+
<label htmlFor="${names.camel}-search" className="sr-only">
|
|
343
|
+
Buscar
|
|
344
|
+
</label>
|
|
345
|
+
<input
|
|
346
|
+
id="${names.camel}-search"
|
|
347
|
+
name="query"
|
|
348
|
+
value={term}
|
|
349
|
+
onChange={(event) => setTerm(event.target.value)}
|
|
350
|
+
placeholder="Buscar por nome"
|
|
351
|
+
className="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white"
|
|
352
|
+
/>
|
|
353
|
+
<button type="submit" className="px-4 py-2 bg-slate-700 rounded text-white text-sm">
|
|
354
|
+
Buscar
|
|
355
|
+
</button>
|
|
356
|
+
</form>
|
|
357
|
+
|
|
358
|
+
{rows.length === 0 ? (
|
|
359
|
+
<p className="text-slate-400 text-sm">Nenhum registro encontrado.</p>
|
|
360
|
+
) : (
|
|
361
|
+
<>
|
|
362
|
+
<table className="w-full text-sm">
|
|
363
|
+
<thead>
|
|
364
|
+
<tr className="text-left text-xs font-mono text-slate-400">
|
|
365
|
+
<th className="pb-2">Nome</th>
|
|
366
|
+
</tr>
|
|
367
|
+
</thead>
|
|
368
|
+
<tbody>
|
|
369
|
+
{rows.map((row) => (
|
|
370
|
+
<tr key={row.id} className="border-t border-slate-800">
|
|
371
|
+
<td className="py-2 text-slate-200">{row.name}</td>
|
|
372
|
+
</tr>
|
|
373
|
+
))}
|
|
374
|
+
</tbody>
|
|
375
|
+
</table>
|
|
376
|
+
<p className="text-xs font-mono text-slate-500">
|
|
377
|
+
{rows.length} de {total}
|
|
378
|
+
</p>
|
|
379
|
+
</>
|
|
380
|
+
)}
|
|
381
|
+
</div>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ============================================================================
|
|
386
|
+
// 6. ORÁCULO DE INVARIANTES
|
|
387
|
+
// ============================================================================
|
|
388
|
+
export const sliceTests = {
|
|
389
|
+
description: 'Invariantes de listagem de ${names.table}',
|
|
390
|
+
cases: [
|
|
391
|
+
{
|
|
392
|
+
name: 'sem sessão retorna UNAUTHORIZED antes de tocar o banco',
|
|
393
|
+
run: async () => {
|
|
394
|
+
const db = new MockDatabaseClient();
|
|
395
|
+
const result = await ${names.actionName}({}, db);
|
|
396
|
+
|
|
397
|
+
if (result.ok || result.error !== 'UNAUTHORIZED') {
|
|
398
|
+
throw new Error(\`esperava UNAUTHORIZED, obteve \${JSON.stringify(result)}\`);
|
|
399
|
+
}
|
|
400
|
+
if (db.calls.length !== 0) {
|
|
401
|
+
throw new Error('autorização deveria acontecer antes de qualquer query');
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
name: 'limite acima do teto volta limitado a ${MAX_LIST_LIMIT}',
|
|
407
|
+
run: async () => {
|
|
408
|
+
const db = new MockDatabaseClient()
|
|
409
|
+
.onQuery(/SELECT id, name FROM/, () => [])
|
|
410
|
+
.onQuery(/SELECT COUNT/, () => [{ total: 0 }]);
|
|
411
|
+
|
|
412
|
+
const result = await ${names.actionName}({ limit: 9999 }, db, createSession({ userId: 'u1', roles: ['user'] }));
|
|
413
|
+
|
|
414
|
+
if (!result.ok || result.value.limit !== ${MAX_LIST_LIMIT}) {
|
|
415
|
+
throw new Error(\`esperava limit ${MAX_LIST_LIMIT}, obteve \${JSON.stringify(result)}\`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
name: 'o termo buscado nunca entra no texto do SQL',
|
|
421
|
+
run: async () => {
|
|
422
|
+
const injection = "'; DROP TABLE ${names.table}; --";
|
|
423
|
+
const db = new MockDatabaseClient()
|
|
424
|
+
.onQuery(/SELECT id, name FROM/, () => [])
|
|
425
|
+
.onQuery(/SELECT COUNT/, () => [{ total: 0 }]);
|
|
426
|
+
|
|
427
|
+
await ${names.actionName}({ query: injection }, db, createSession({ userId: 'u1', roles: ['user'] }));
|
|
428
|
+
|
|
429
|
+
const statement = db.calls.find((call) => call.sql.includes('SELECT id, name FROM'));
|
|
430
|
+
if (!statement || statement.sql.includes('DROP TABLE')) {
|
|
431
|
+
throw new Error('a entrada do usuário entrou no texto do SQL');
|
|
432
|
+
}
|
|
433
|
+
if (!statement.sql.includes('$1')) {
|
|
434
|
+
throw new Error('a consulta deveria usar placeholder');
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
]
|
|
439
|
+
};
|
|
440
|
+
`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** `update`: só os campos enviados, e o nome da coluna vem de uma allowlist. */
|
|
444
|
+
function updateTemplate(names: TemplateNames): string {
|
|
445
|
+
return `${imports(false)}
|
|
446
|
+
|
|
447
|
+
// ============================================================================
|
|
448
|
+
// 1. CONTRATO DE ENTRADA (TypeBox) — todos os campos editáveis são opcionais
|
|
449
|
+
// ============================================================================
|
|
450
|
+
export const ${names.schemaName} = Type.Object({
|
|
451
|
+
id: Type.String({ minLength: 1 }),
|
|
452
|
+
name: Type.Optional(Type.String({ minLength: 2, maxLength: 120 }))
|
|
453
|
+
});
|
|
454
|
+
export type ${names.inputName} = Static<typeof ${names.schemaName}>;
|
|
455
|
+
|
|
456
|
+
// ============================================================================
|
|
457
|
+
// 2. MODELAGEM DE DOMÍNIO (Result<T, E>)
|
|
458
|
+
// ============================================================================
|
|
459
|
+
export type ${names.outputName} = Result<
|
|
460
|
+
{ id: string; updated: number },
|
|
461
|
+
'INVALID_SCHEMA' | 'NO_DATABASE' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOTHING_TO_UPDATE' | 'NOT_FOUND'
|
|
462
|
+
>;
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Allowlist de campos editáveis: o nome da coluna NUNCA vem do payload, então o
|
|
466
|
+
* SQL é montado só com o que está aqui. É o que separa update parcial de injeção.
|
|
467
|
+
*/
|
|
468
|
+
const UPDATABLE_COLUMNS: Record<string, string> = {
|
|
469
|
+
name: 'name'
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// ============================================================================
|
|
473
|
+
// 3. SERVER ACTION
|
|
474
|
+
// ============================================================================
|
|
475
|
+
export async function ${names.actionName}(
|
|
476
|
+
payload: unknown,
|
|
477
|
+
db?: DatabaseClient,
|
|
478
|
+
session?: SessionContext
|
|
479
|
+
): Promise<${names.outputName}> {
|
|
480
|
+
const auth = requireAuth(session, ['writer']);
|
|
481
|
+
if (!auth.ok) {
|
|
482
|
+
return Err(auth.error);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!db) {
|
|
486
|
+
return Err('NO_DATABASE');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (!Value.Check(${names.schemaName}, payload)) {
|
|
490
|
+
return Err('INVALID_SCHEMA');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const input = payload as ${names.inputName};
|
|
494
|
+
|
|
495
|
+
const existing = await db.query<{ id: string }>(\`SELECT id FROM ${names.table} WHERE id = $1\`, [input.id]);
|
|
496
|
+
if (existing.length === 0) {
|
|
497
|
+
return Err('NOT_FOUND');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const assignments: string[] = [];
|
|
501
|
+
const params: unknown[] = [];
|
|
502
|
+
|
|
503
|
+
for (const [field, column] of Object.entries(UPDATABLE_COLUMNS)) {
|
|
504
|
+
const value = (input as Record<string, unknown>)[field];
|
|
505
|
+
if (value === undefined) {
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
params.push(value);
|
|
509
|
+
assignments.push(\`\${column} = $\${params.length}\`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (assignments.length === 0) {
|
|
513
|
+
return Err('NOTHING_TO_UPDATE');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
params.push(input.id);
|
|
517
|
+
await db.query(\`UPDATE ${names.table} SET \${assignments.join(', ')} WHERE id = $\${params.length}\`, params);
|
|
518
|
+
|
|
519
|
+
return Ok({ id: input.id, updated: assignments.length });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ============================================================================
|
|
523
|
+
// 4. UI REACT
|
|
524
|
+
// ============================================================================
|
|
525
|
+
export interface ${names.pascal}TriggerProps {
|
|
526
|
+
id?: string;
|
|
527
|
+
onSubmitAction?: (payload: unknown) => Promise<${names.outputName}>;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function ${names.componentName}({ id = '', onSubmitAction }: ${names.pascal}TriggerProps) {
|
|
531
|
+
const [feedback, setFeedback] = useState<string | null>(null);
|
|
532
|
+
|
|
533
|
+
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
534
|
+
event.preventDefault();
|
|
535
|
+
if (!onSubmitAction) return;
|
|
536
|
+
|
|
537
|
+
const formData = new FormData(event.currentTarget);
|
|
538
|
+
const payload: Record<string, unknown> = { id: formData.get('id') };
|
|
539
|
+
const name = formData.get('name');
|
|
540
|
+
if (typeof name === 'string' && name.length > 0) {
|
|
541
|
+
payload.name = name;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const result = await onSubmitAction(payload);
|
|
545
|
+
setFeedback(result.ok ? \`Atualizado (\${result.value.updated} campo(s))\` : \`Erro: \${result.error}\`);
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
return (
|
|
549
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
|
550
|
+
<label htmlFor="${names.camel}-id" className="text-xs font-mono text-slate-400">
|
|
551
|
+
ID
|
|
552
|
+
</label>
|
|
553
|
+
<input
|
|
554
|
+
id="${names.camel}-id"
|
|
555
|
+
name="id"
|
|
556
|
+
defaultValue={id}
|
|
557
|
+
required
|
|
558
|
+
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white"
|
|
559
|
+
/>
|
|
560
|
+
|
|
561
|
+
<label htmlFor="${names.camel}-name" className="text-xs font-mono text-slate-400">
|
|
562
|
+
Nome (vazio = não altera)
|
|
563
|
+
</label>
|
|
564
|
+
<input
|
|
565
|
+
id="${names.camel}-name"
|
|
566
|
+
name="name"
|
|
567
|
+
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white"
|
|
568
|
+
/>
|
|
569
|
+
|
|
570
|
+
<button type="submit" className="px-4 py-2 bg-cyan-600 rounded text-white text-sm">
|
|
571
|
+
Salvar
|
|
572
|
+
</button>
|
|
573
|
+
{feedback && <p className="text-xs font-mono text-cyan-300">{feedback}</p>}
|
|
574
|
+
</form>
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ============================================================================
|
|
579
|
+
// 5. ORÁCULO DE INVARIANTES
|
|
580
|
+
// ============================================================================
|
|
581
|
+
export const sliceTests = {
|
|
582
|
+
description: 'Invariantes de atualização parcial de ${names.table}',
|
|
583
|
+
cases: [
|
|
584
|
+
{
|
|
585
|
+
name: 'sem o papel exigido retorna FORBIDDEN',
|
|
586
|
+
run: async () => {
|
|
587
|
+
const result = await ${names.actionName}(
|
|
588
|
+
{ id: 'x', name: 'Novo' },
|
|
589
|
+
new MockDatabaseClient(),
|
|
590
|
+
createSession({ userId: 'u1', roles: ['viewer'] })
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
if (result.ok || result.error !== 'FORBIDDEN') {
|
|
594
|
+
throw new Error(\`esperava FORBIDDEN, obteve \${JSON.stringify(result)}\`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
name: 'payload sem nenhum campo editável retorna NOTHING_TO_UPDATE',
|
|
600
|
+
run: async () => {
|
|
601
|
+
const db = new MockDatabaseClient().onQuery(/SELECT id FROM/, () => [{ id: 'x' }]);
|
|
602
|
+
|
|
603
|
+
const result = await ${names.actionName}({ id: 'x' }, db, createSession({ userId: 'u1', roles: ['writer'] }));
|
|
604
|
+
|
|
605
|
+
if (result.ok || result.error !== 'NOTHING_TO_UPDATE') {
|
|
606
|
+
throw new Error(\`esperava NOTHING_TO_UPDATE, obteve \${JSON.stringify(result)}\`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
name: 'registro inexistente retorna NOT_FOUND',
|
|
612
|
+
run: async () => {
|
|
613
|
+
const db = new MockDatabaseClient().onQuery(/SELECT id FROM/, () => []);
|
|
614
|
+
|
|
615
|
+
const result = await ${names.actionName}(
|
|
616
|
+
{ id: 'x', name: 'Novo' },
|
|
617
|
+
db,
|
|
618
|
+
createSession({ userId: 'u1', roles: ['writer'] })
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
if (result.ok || result.error !== 'NOT_FOUND') {
|
|
622
|
+
throw new Error(\`esperava NOT_FOUND, obteve \${JSON.stringify(result)}\`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
name: 'só as colunas da allowlist aparecem no UPDATE',
|
|
628
|
+
run: async () => {
|
|
629
|
+
const db = new MockDatabaseClient().onQuery(/SELECT id FROM/, () => [{ id: 'x' }]);
|
|
630
|
+
|
|
631
|
+
await ${names.actionName}(
|
|
632
|
+
{ id: 'x', name: 'Novo', evil: 'name = 1 --' },
|
|
633
|
+
db,
|
|
634
|
+
createSession({ userId: 'u1', roles: ['writer'] })
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
const update = db.calls.find((call) => call.sql.startsWith('UPDATE'));
|
|
638
|
+
if (!update || update.sql.includes('evil') || update.sql.includes('--')) {
|
|
639
|
+
throw new Error(\`a allowlist vazou: \${update?.sql ?? 'nenhum UPDATE'}\`);
|
|
640
|
+
}
|
|
641
|
+
if (!update.sql.includes('name = $1')) {
|
|
642
|
+
throw new Error(\`UPDATE inesperado: \${update.sql}\`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
]
|
|
647
|
+
};
|
|
648
|
+
`;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** `delete`: guarda de existência antes de apagar. */
|
|
652
|
+
function deleteTemplate(names: TemplateNames): string {
|
|
653
|
+
return `${imports(false)}
|
|
654
|
+
|
|
655
|
+
// ============================================================================
|
|
656
|
+
// 1. CONTRATO DE ENTRADA (TypeBox)
|
|
657
|
+
// ============================================================================
|
|
658
|
+
export const ${names.schemaName} = Type.Object({
|
|
659
|
+
id: Type.String({ minLength: 1 })
|
|
660
|
+
});
|
|
661
|
+
export type ${names.inputName} = Static<typeof ${names.schemaName}>;
|
|
662
|
+
|
|
663
|
+
// ============================================================================
|
|
664
|
+
// 2. MODELAGEM DE DOMÍNIO (Result<T, E>)
|
|
665
|
+
// ============================================================================
|
|
666
|
+
export type ${names.outputName} = Result<
|
|
667
|
+
{ id: string; deleted: true },
|
|
668
|
+
'INVALID_SCHEMA' | 'NO_DATABASE' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND'
|
|
669
|
+
>;
|
|
670
|
+
|
|
671
|
+
// ============================================================================
|
|
672
|
+
// 3. SERVER ACTION
|
|
673
|
+
// ============================================================================
|
|
674
|
+
export async function ${names.actionName}(
|
|
675
|
+
payload: unknown,
|
|
676
|
+
db?: DatabaseClient,
|
|
677
|
+
session?: SessionContext
|
|
678
|
+
): Promise<${names.outputName}> {
|
|
679
|
+
const auth = requireAuth(session, ['admin']);
|
|
680
|
+
if (!auth.ok) {
|
|
681
|
+
return Err(auth.error);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (!db) {
|
|
685
|
+
return Err('NO_DATABASE');
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (!Value.Check(${names.schemaName}, payload)) {
|
|
689
|
+
return Err('INVALID_SCHEMA');
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const input = payload as ${names.inputName};
|
|
693
|
+
|
|
694
|
+
const existing = await db.query<{ id: string }>(\`SELECT id FROM ${names.table} WHERE id = $1\`, [input.id]);
|
|
695
|
+
if (existing.length === 0) {
|
|
696
|
+
return Err('NOT_FOUND');
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
await db.query(\`DELETE FROM ${names.table} WHERE id = $1\`, [input.id]);
|
|
700
|
+
|
|
701
|
+
return Ok({ id: input.id, deleted: true });
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ============================================================================
|
|
705
|
+
// 4. UI REACT
|
|
706
|
+
// ============================================================================
|
|
707
|
+
export interface ${names.pascal}TriggerProps {
|
|
708
|
+
id?: string;
|
|
709
|
+
onSubmitAction?: (payload: unknown) => Promise<${names.outputName}>;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export function ${names.componentName}({ id = '', onSubmitAction }: ${names.pascal}TriggerProps) {
|
|
713
|
+
const [feedback, setFeedback] = useState<string | null>(null);
|
|
714
|
+
|
|
715
|
+
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
716
|
+
event.preventDefault();
|
|
717
|
+
if (!onSubmitAction) return;
|
|
718
|
+
|
|
719
|
+
const formData = new FormData(event.currentTarget);
|
|
720
|
+
const result = await onSubmitAction({ id: formData.get('id') });
|
|
721
|
+
|
|
722
|
+
setFeedback(result.ok ? 'Registro apagado' : \`Erro: \${result.error}\`);
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
return (
|
|
726
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
|
727
|
+
<label htmlFor="${names.camel}-id" className="text-xs font-mono text-slate-400">
|
|
728
|
+
ID
|
|
729
|
+
</label>
|
|
730
|
+
<input
|
|
731
|
+
id="${names.camel}-id"
|
|
732
|
+
name="id"
|
|
733
|
+
defaultValue={id}
|
|
734
|
+
required
|
|
735
|
+
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white"
|
|
736
|
+
/>
|
|
737
|
+
|
|
738
|
+
<button type="submit" className="px-4 py-2 bg-rose-600 rounded text-white text-sm">
|
|
739
|
+
Apagar
|
|
740
|
+
</button>
|
|
741
|
+
{feedback && <p className="text-xs font-mono text-rose-300">{feedback}</p>}
|
|
742
|
+
</form>
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ============================================================================
|
|
747
|
+
// 5. ORÁCULO DE INVARIANTES
|
|
748
|
+
// ============================================================================
|
|
749
|
+
export const sliceTests = {
|
|
750
|
+
description: 'Invariantes de remoção de ${names.table}',
|
|
751
|
+
cases: [
|
|
752
|
+
{
|
|
753
|
+
name: 'sem o papel exigido retorna FORBIDDEN',
|
|
754
|
+
run: async () => {
|
|
755
|
+
const result = await ${names.actionName}(
|
|
756
|
+
{ id: 'x' },
|
|
757
|
+
new MockDatabaseClient(),
|
|
758
|
+
createSession({ userId: 'u1', roles: ['writer'] })
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
if (result.ok || result.error !== 'FORBIDDEN') {
|
|
762
|
+
throw new Error(\`esperava FORBIDDEN, obteve \${JSON.stringify(result)}\`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
name: 'registro inexistente retorna NOT_FOUND e nada é apagado',
|
|
768
|
+
run: async () => {
|
|
769
|
+
const db = new MockDatabaseClient().onQuery(/SELECT id FROM/, () => []);
|
|
770
|
+
|
|
771
|
+
const result = await ${names.actionName}({ id: 'x' }, db, createSession({ userId: 'u1', roles: ['admin'] }));
|
|
772
|
+
|
|
773
|
+
if (result.ok || result.error !== 'NOT_FOUND') {
|
|
774
|
+
throw new Error(\`esperava NOT_FOUND, obteve \${JSON.stringify(result)}\`);
|
|
775
|
+
}
|
|
776
|
+
if (db.calls.some((call) => call.sql.startsWith('DELETE'))) {
|
|
777
|
+
throw new Error('não deveria apagar quando o registro não existe');
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
]
|
|
782
|
+
};
|
|
783
|
+
`;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** `login`: verifica credenciais, assina a sessão e devolve o token que o cookie carrega. */
|
|
787
|
+
function loginTemplate(names: TemplateNames): string {
|
|
788
|
+
return `import React, { useState } from 'react';
|
|
789
|
+
import { Type, Static } from '@sinclair/typebox';
|
|
790
|
+
import { Value } from '@sinclair/typebox/value';
|
|
791
|
+
import {
|
|
792
|
+
type DatabaseClient,
|
|
793
|
+
Err,
|
|
794
|
+
MockDatabaseClient,
|
|
795
|
+
Ok,
|
|
796
|
+
type Result,
|
|
797
|
+
type SessionContext,
|
|
798
|
+
signSessionToken,
|
|
799
|
+
verifySessionToken
|
|
800
|
+
} from 'synapsejs';
|
|
801
|
+
import { storeSession } from 'synapsejs/client';
|
|
802
|
+
|
|
803
|
+
// ============================================================================
|
|
804
|
+
// 1. CONTRATO DE ENTRADA (TypeBox)
|
|
805
|
+
// ============================================================================
|
|
806
|
+
export const ${names.schemaName} = Type.Object({
|
|
807
|
+
email: Type.String({ minLength: 3, maxLength: 200 }),
|
|
808
|
+
password: Type.String({ minLength: 8, maxLength: 200 })
|
|
809
|
+
});
|
|
810
|
+
export type ${names.inputName} = Static<typeof ${names.schemaName}>;
|
|
811
|
+
|
|
812
|
+
// ============================================================================
|
|
813
|
+
// 2. TABELA (esta fatia é dona da DDL: as outras fatias de auth devem omitir o sliceSchema)
|
|
814
|
+
// ============================================================================
|
|
815
|
+
export const sliceSchema = \`
|
|
816
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
817
|
+
id TEXT PRIMARY KEY,
|
|
818
|
+
email TEXT NOT NULL UNIQUE,
|
|
819
|
+
password_hash TEXT NOT NULL,
|
|
820
|
+
roles TEXT NOT NULL DEFAULT 'user',
|
|
821
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
822
|
+
);
|
|
823
|
+
\`;
|
|
824
|
+
|
|
825
|
+
// Para criar o primeiro usuário, num seed fora da fatia:
|
|
826
|
+
// Bun.password.hash('uma-senha-com-8-ou-mais')
|
|
827
|
+
// Nunca insira password_hash na mão e nunca guarde a senha em claro.
|
|
828
|
+
|
|
829
|
+
// ============================================================================
|
|
830
|
+
// 3. MODELAGEM DE DOMÍNIO (Result<T, E>)
|
|
831
|
+
// ============================================================================
|
|
832
|
+
export type ${names.outputName} = Result<
|
|
833
|
+
{ token: string; roles: string[]; expiresAt: number },
|
|
834
|
+
'INVALID_SCHEMA' | 'NO_DATABASE' | 'MISSING_SECRET' | 'INVALID_CREDENTIALS'
|
|
835
|
+
>;
|
|
836
|
+
|
|
837
|
+
const SESSION_MAX_AGE_SECONDS = 43_200;
|
|
838
|
+
|
|
839
|
+
/** Hash descartável: sem usuário, a verificação custa o mesmo tempo e não conta quem existe. */
|
|
840
|
+
const DECOY_HASH = '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy';
|
|
841
|
+
|
|
842
|
+
// ============================================================================
|
|
843
|
+
// 4. SERVER ACTION — a única peça que assina
|
|
844
|
+
// ============================================================================
|
|
845
|
+
export async function ${names.actionName}(
|
|
846
|
+
payload: unknown,
|
|
847
|
+
db?: DatabaseClient,
|
|
848
|
+
_session?: SessionContext
|
|
849
|
+
): Promise<${names.outputName}> {
|
|
850
|
+
if (!db) {
|
|
851
|
+
return Err('NO_DATABASE');
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (!Value.Check(${names.schemaName}, payload)) {
|
|
855
|
+
return Err('INVALID_SCHEMA');
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const secret = process.env.SYNAPSE_SESSION_SECRET;
|
|
859
|
+
if (!secret) {
|
|
860
|
+
// Sem segredo o servidor não distingue um token assinado de um header que qualquer
|
|
861
|
+
// cliente escreve, então o login falha alto em vez de emitir algo que não vale.
|
|
862
|
+
return Err('MISSING_SECRET');
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const input = payload as ${names.inputName};
|
|
866
|
+
const email = input.email.trim().toLowerCase();
|
|
867
|
+
|
|
868
|
+
const rows = await db.query<{ id: string; password_hash: string; roles: string }>(
|
|
869
|
+
'SELECT id, password_hash, roles FROM users WHERE email = $1',
|
|
870
|
+
[email]
|
|
871
|
+
);
|
|
872
|
+
const user = rows[0];
|
|
873
|
+
|
|
874
|
+
const valid = await Bun.password.verify(input.password, user?.password_hash ?? DECOY_HASH).catch(() => false);
|
|
875
|
+
if (!user || !valid) {
|
|
876
|
+
return Err('INVALID_CREDENTIALS');
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const roles = user.roles
|
|
880
|
+
.split(',')
|
|
881
|
+
.map((role) => role.trim())
|
|
882
|
+
.filter((role) => role.length > 0);
|
|
883
|
+
|
|
884
|
+
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_MAX_AGE_SECONDS;
|
|
885
|
+
|
|
886
|
+
return Ok({
|
|
887
|
+
token: signSessionToken({ userId: user.id, roles }, secret, SESSION_MAX_AGE_SECONDS),
|
|
888
|
+
roles,
|
|
889
|
+
expiresAt
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// ============================================================================
|
|
894
|
+
// 5. UI REACT — grava o token no cookie e recarrega
|
|
895
|
+
// ============================================================================
|
|
896
|
+
export interface ${names.pascal}TriggerProps {
|
|
897
|
+
onSubmitAction?: (payload: unknown) => Promise<${names.outputName}>;
|
|
898
|
+
redirectTo?: string;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
export function ${names.componentName}({ onSubmitAction, redirectTo = '/' }: ${names.pascal}TriggerProps) {
|
|
902
|
+
const [feedback, setFeedback] = useState<string | null>(null);
|
|
903
|
+
|
|
904
|
+
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
905
|
+
event.preventDefault();
|
|
906
|
+
if (!onSubmitAction) return;
|
|
907
|
+
|
|
908
|
+
const formData = new FormData(event.currentTarget);
|
|
909
|
+
const result = await onSubmitAction({
|
|
910
|
+
email: formData.get('email'),
|
|
911
|
+
password: formData.get('password')
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
if (!result.ok) {
|
|
915
|
+
setFeedback(result.error === 'INVALID_CREDENTIALS' ? 'E-mail ou senha inválidos' : \`Erro: \${result.error}\`);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Daqui em diante o servidor reconhece a sessão pela assinatura: o cookie vira
|
|
920
|
+
// Authorization: Bearer nas próximas chamadas.
|
|
921
|
+
storeSession(result.value.token, result.value.roles, result.value.expiresAt - Math.floor(Date.now() / 1000));
|
|
922
|
+
(globalThis as { location?: { assign?: (url: string) => void } }).location?.assign?.(redirectTo);
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
return (
|
|
926
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-3 max-w-sm">
|
|
927
|
+
<label htmlFor="${names.camel}-email" className="text-xs font-mono text-slate-400">
|
|
928
|
+
E-mail
|
|
929
|
+
</label>
|
|
930
|
+
<input
|
|
931
|
+
id="${names.camel}-email"
|
|
932
|
+
name="email"
|
|
933
|
+
type="email"
|
|
934
|
+
required
|
|
935
|
+
autoComplete="username"
|
|
936
|
+
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white"
|
|
937
|
+
/>
|
|
938
|
+
|
|
939
|
+
<label htmlFor="${names.camel}-password" className="text-xs font-mono text-slate-400">
|
|
940
|
+
Senha
|
|
941
|
+
</label>
|
|
942
|
+
<input
|
|
943
|
+
id="${names.camel}-password"
|
|
944
|
+
name="password"
|
|
945
|
+
type="password"
|
|
946
|
+
required
|
|
947
|
+
minLength={8}
|
|
948
|
+
autoComplete="current-password"
|
|
949
|
+
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white"
|
|
950
|
+
/>
|
|
951
|
+
|
|
952
|
+
<button type="submit" className="px-4 py-2 bg-cyan-600 rounded text-white text-sm">
|
|
953
|
+
Entrar
|
|
954
|
+
</button>
|
|
955
|
+
{feedback && <p className="text-xs font-mono text-rose-300">{feedback}</p>}
|
|
956
|
+
</form>
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ============================================================================
|
|
961
|
+
// 6. ORÁCULO DE INVARIANTES
|
|
962
|
+
// ============================================================================
|
|
963
|
+
export const sliceTests = {
|
|
964
|
+
description: 'Invariantes de autenticação de users',
|
|
965
|
+
cases: [
|
|
966
|
+
{
|
|
967
|
+
name: 'senha correta devolve um token que o próprio servidor aceita',
|
|
968
|
+
run: async () => {
|
|
969
|
+
process.env.SYNAPSE_SESSION_SECRET = 'segredo-de-teste';
|
|
970
|
+
const hash = await Bun.password.hash('senha-com-oito');
|
|
971
|
+
const db = new MockDatabaseClient().onQuery(/SELECT id, password_hash, roles/, () => [
|
|
972
|
+
{ id: 'user-1', password_hash: hash, roles: 'admin,billing' }
|
|
973
|
+
]);
|
|
974
|
+
|
|
975
|
+
const result = await ${names.actionName}({ email: ' Admin@Loja.com ', password: 'senha-com-oito' }, db);
|
|
976
|
+
|
|
977
|
+
if (!result.ok) {
|
|
978
|
+
throw new Error(\`esperava sucesso, obteve \${JSON.stringify(result)}\`);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const verified = verifySessionToken(result.value.token, 'segredo-de-teste');
|
|
982
|
+
if (!verified.ok) {
|
|
983
|
+
throw new Error(\`o servidor recusaria o token emitido: \${verified.error}\`);
|
|
984
|
+
}
|
|
985
|
+
if (verified.value.userId !== 'user-1' || !verified.value.roles.includes('billing')) {
|
|
986
|
+
throw new Error(\`claims inesperadas: \${JSON.stringify(verified.value)}\`);
|
|
987
|
+
}
|
|
988
|
+
if (result.value.roles.join(',') !== 'admin,billing') {
|
|
989
|
+
throw new Error(\`papéis inesperados: \${result.value.roles.join(',')}\`);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
name: 'senha errada retorna INVALID_CREDENTIALS',
|
|
995
|
+
run: async () => {
|
|
996
|
+
process.env.SYNAPSE_SESSION_SECRET = 'segredo-de-teste';
|
|
997
|
+
const hash = await Bun.password.hash('senha-com-oito');
|
|
998
|
+
const db = new MockDatabaseClient().onQuery(/SELECT id, password_hash, roles/, () => [
|
|
999
|
+
{ id: 'user-1', password_hash: hash, roles: 'user' }
|
|
1000
|
+
]);
|
|
1001
|
+
|
|
1002
|
+
const result = await ${names.actionName}({ email: 'a@b.com', password: 'senha-errada-1' }, db);
|
|
1003
|
+
|
|
1004
|
+
if (result.ok || result.error !== 'INVALID_CREDENTIALS') {
|
|
1005
|
+
throw new Error(\`esperava INVALID_CREDENTIALS, obteve \${JSON.stringify(result)}\`);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
},
|
|
1009
|
+
{
|
|
1010
|
+
name: 'usuário inexistente retorna INVALID_CREDENTIALS sem revelar que não existe',
|
|
1011
|
+
run: async () => {
|
|
1012
|
+
process.env.SYNAPSE_SESSION_SECRET = 'segredo-de-teste';
|
|
1013
|
+
const db = new MockDatabaseClient().onQuery(/SELECT id, password_hash, roles/, () => []);
|
|
1014
|
+
|
|
1015
|
+
const result = await ${names.actionName}({ email: 'ninguem@b.com', password: 'senha-com-oito' }, db);
|
|
1016
|
+
|
|
1017
|
+
if (result.ok || result.error !== 'INVALID_CREDENTIALS') {
|
|
1018
|
+
throw new Error(\`esperava INVALID_CREDENTIALS, obteve \${JSON.stringify(result)}\`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
},
|
|
1022
|
+
{
|
|
1023
|
+
name: 'sem SYNAPSE_SESSION_SECRET o login falha em vez de emitir token forjável',
|
|
1024
|
+
run: async () => {
|
|
1025
|
+
const previous = process.env.SYNAPSE_SESSION_SECRET;
|
|
1026
|
+
delete process.env.SYNAPSE_SESSION_SECRET;
|
|
1027
|
+
|
|
1028
|
+
const result = await ${names.actionName}(
|
|
1029
|
+
{ email: 'a@b.com', password: 'senha-com-oito' },
|
|
1030
|
+
new MockDatabaseClient()
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
process.env.SYNAPSE_SESSION_SECRET = previous;
|
|
1034
|
+
|
|
1035
|
+
if (result.ok || result.error !== 'MISSING_SECRET') {
|
|
1036
|
+
throw new Error(\`esperava MISSING_SECRET, obteve \${JSON.stringify(result)}\`);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
},
|
|
1040
|
+
{
|
|
1041
|
+
name: 'payload fora do contrato retorna INVALID_SCHEMA',
|
|
1042
|
+
run: async () => {
|
|
1043
|
+
const result = await ${names.actionName}(
|
|
1044
|
+
{ email: 'a@b.com', password: 'curta' },
|
|
1045
|
+
new MockDatabaseClient()
|
|
1046
|
+
);
|
|
1047
|
+
|
|
1048
|
+
if (result.ok || result.error !== 'INVALID_SCHEMA') {
|
|
1049
|
+
throw new Error(\`esperava INVALID_SCHEMA, obteve \${JSON.stringify(result)}\`);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
]
|
|
1054
|
+
};
|
|
1055
|
+
`;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function oauthGithubTemplate(names: TemplateNames): string {
|
|
1059
|
+
return `import React, { useState } from 'react';
|
|
1060
|
+
import { Type, Static } from '@sinclair/typebox';
|
|
1061
|
+
import { Value } from '@sinclair/typebox/value';
|
|
1062
|
+
import {
|
|
1063
|
+
type DatabaseClient,
|
|
1064
|
+
Err,
|
|
1065
|
+
MockDatabaseClient,
|
|
1066
|
+
Ok,
|
|
1067
|
+
type Result,
|
|
1068
|
+
type SessionContext,
|
|
1069
|
+
signSessionToken
|
|
1070
|
+
} from 'synapsejs';
|
|
1071
|
+
|
|
1072
|
+
// ============================================================================
|
|
1073
|
+
// 1. CONTRATO DE ENTRADA (TypeBox)
|
|
1074
|
+
// ============================================================================
|
|
1075
|
+
export const ${names.schemaName} = Type.Object({
|
|
1076
|
+
code: Type.String({ minLength: 1, maxLength: 500 }),
|
|
1077
|
+
state: Type.Optional(Type.String({ maxLength: 500 }))
|
|
1078
|
+
});
|
|
1079
|
+
export type ${names.inputName} = Static<typeof ${names.schemaName}>;
|
|
1080
|
+
|
|
1081
|
+
// ============================================================================
|
|
1082
|
+
// 2. TABELA (DDL auto-migrado)
|
|
1083
|
+
// ============================================================================
|
|
1084
|
+
export const sliceSchema = \`
|
|
1085
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
1086
|
+
id TEXT PRIMARY KEY,
|
|
1087
|
+
email TEXT NOT NULL UNIQUE,
|
|
1088
|
+
roles TEXT NOT NULL DEFAULT 'user',
|
|
1089
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
|
1093
|
+
id TEXT PRIMARY KEY,
|
|
1094
|
+
user_id TEXT NOT NULL,
|
|
1095
|
+
provider TEXT NOT NULL,
|
|
1096
|
+
provider_user_id TEXT NOT NULL,
|
|
1097
|
+
email TEXT,
|
|
1098
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
1099
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
1100
|
+
UNIQUE(provider, provider_user_id)
|
|
1101
|
+
);
|
|
1102
|
+
\`;
|
|
1103
|
+
|
|
1104
|
+
// ============================================================================
|
|
1105
|
+
// 3. MODELAGEM DE DOMÍNIO (Result<T, E>)
|
|
1106
|
+
// ============================================================================
|
|
1107
|
+
export type ${names.outputName} = Result<
|
|
1108
|
+
{ token: string; roles: string[]; expiresAt: number; userId: string },
|
|
1109
|
+
'INVALID_SCHEMA' | 'NO_DATABASE' | 'MISSING_SECRET' | 'OAUTH_CONFIG_MISSING' | 'OAUTH_EXCHANGE_FAILED'
|
|
1110
|
+
>;
|
|
1111
|
+
|
|
1112
|
+
const SESSION_MAX_AGE_SECONDS = 43_200;
|
|
1113
|
+
|
|
1114
|
+
// ============================================================================
|
|
1115
|
+
// 4. SERVER ACTION
|
|
1116
|
+
// ============================================================================
|
|
1117
|
+
export async function ${names.actionName}(
|
|
1118
|
+
payload: unknown,
|
|
1119
|
+
db?: DatabaseClient,
|
|
1120
|
+
_session?: SessionContext
|
|
1121
|
+
): Promise<${names.outputName}> {
|
|
1122
|
+
if (!db) {
|
|
1123
|
+
return Err('NO_DATABASE');
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (!Value.Check(${names.schemaName}, payload)) {
|
|
1127
|
+
return Err('INVALID_SCHEMA');
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const secret = process.env.SYNAPSE_SESSION_SECRET;
|
|
1131
|
+
if (!secret) {
|
|
1132
|
+
return Err('MISSING_SECRET');
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const clientId = process.env.GITHUB_CLIENT_ID;
|
|
1136
|
+
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
|
|
1137
|
+
if (!clientId || !clientSecret) {
|
|
1138
|
+
return Err('OAUTH_CONFIG_MISSING');
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const input = payload as ${names.inputName};
|
|
1142
|
+
|
|
1143
|
+
let tokenRes: Response;
|
|
1144
|
+
try {
|
|
1145
|
+
tokenRes = await fetch('https://github.com/login/oauth/access_token', {
|
|
1146
|
+
method: 'POST',
|
|
1147
|
+
headers: {
|
|
1148
|
+
'Accept': 'application/json',
|
|
1149
|
+
'Content-Type': 'application/json'
|
|
1150
|
+
},
|
|
1151
|
+
body: JSON.stringify({
|
|
1152
|
+
client_id: clientId,
|
|
1153
|
+
client_secret: clientSecret,
|
|
1154
|
+
code: input.code
|
|
1155
|
+
})
|
|
1156
|
+
});
|
|
1157
|
+
} catch {
|
|
1158
|
+
return Err('OAUTH_EXCHANGE_FAILED');
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (!tokenRes.ok) {
|
|
1162
|
+
return Err('OAUTH_EXCHANGE_FAILED');
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// biome-ignore lint/suspicious/noExplicitAny: boundary OAuth externo
|
|
1166
|
+
const tokenData = (await tokenRes.json().catch(() => null)) as any;
|
|
1167
|
+
if (!tokenData?.access_token) {
|
|
1168
|
+
return Err('OAUTH_EXCHANGE_FAILED');
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
let userRes: Response;
|
|
1172
|
+
try {
|
|
1173
|
+
userRes = await fetch('https://api.github.com/user', {
|
|
1174
|
+
headers: {
|
|
1175
|
+
'Accept': 'application/json',
|
|
1176
|
+
'Authorization': \`Bearer \${tokenData.access_token}\`,
|
|
1177
|
+
'User-Agent': 'SynapseJS-OAuth'
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
} catch {
|
|
1181
|
+
return Err('OAUTH_EXCHANGE_FAILED');
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
if (!userRes.ok) {
|
|
1185
|
+
return Err('OAUTH_EXCHANGE_FAILED');
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// biome-ignore lint/suspicious/noExplicitAny: boundary OAuth externo
|
|
1189
|
+
const profile = (await userRes.json().catch(() => null)) as any;
|
|
1190
|
+
if (!profile?.id) {
|
|
1191
|
+
return Err('OAUTH_EXCHANGE_FAILED');
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const providerUserId = String(profile.id);
|
|
1195
|
+
const email = (profile.email || \`\${profile.login || profile.id}@github.synapse.local\`).toLowerCase();
|
|
1196
|
+
|
|
1197
|
+
let userId: string;
|
|
1198
|
+
let userRoles = ['user'];
|
|
1199
|
+
|
|
1200
|
+
const existingAccounts = await db.query<{ user_id: string }>(
|
|
1201
|
+
'SELECT user_id FROM oauth_accounts WHERE provider = $1 AND provider_user_id = $2',
|
|
1202
|
+
['github', providerUserId]
|
|
1203
|
+
);
|
|
1204
|
+
|
|
1205
|
+
if (existingAccounts.length > 0) {
|
|
1206
|
+
userId = existingAccounts[0].user_id;
|
|
1207
|
+
const userRows = await db.query<{ roles: string }>('SELECT roles FROM users WHERE id = $1', [userId]);
|
|
1208
|
+
if (userRows.length > 0 && userRows[0].roles) {
|
|
1209
|
+
userRoles = userRows[0].roles.split(',').map((r) => r.trim()).filter(Boolean);
|
|
1210
|
+
}
|
|
1211
|
+
} else {
|
|
1212
|
+
const existingUsers = await db.query<{ id: string; roles: string }>(
|
|
1213
|
+
'SELECT id, roles FROM users WHERE email = $1',
|
|
1214
|
+
[email]
|
|
1215
|
+
);
|
|
1216
|
+
|
|
1217
|
+
if (existingUsers.length > 0) {
|
|
1218
|
+
userId = existingUsers[0].id;
|
|
1219
|
+
if (existingUsers[0].roles) {
|
|
1220
|
+
userRoles = existingUsers[0].roles.split(',').map((r) => r.trim()).filter(Boolean);
|
|
1221
|
+
}
|
|
1222
|
+
} else {
|
|
1223
|
+
userId = \`u_\${Math.random().toString(36).substring(2, 10)}\`;
|
|
1224
|
+
await db.query(
|
|
1225
|
+
'INSERT INTO users (id, email, roles) VALUES ($1, $2, $3)',
|
|
1226
|
+
[userId, email, 'user']
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const accountId = \`oa_\${Math.random().toString(36).substring(2, 10)}\`;
|
|
1231
|
+
await db.query(
|
|
1232
|
+
'INSERT INTO oauth_accounts (id, user_id, provider, provider_user_id, email) VALUES ($1, $2, $3, $4, $5)',
|
|
1233
|
+
[accountId, userId, 'github', providerUserId, email]
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_MAX_AGE_SECONDS;
|
|
1238
|
+
const token = signSessionToken({ userId, roles: userRoles }, secret, SESSION_MAX_AGE_SECONDS);
|
|
1239
|
+
|
|
1240
|
+
return Ok({
|
|
1241
|
+
token,
|
|
1242
|
+
roles: userRoles,
|
|
1243
|
+
expiresAt,
|
|
1244
|
+
userId
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// ============================================================================
|
|
1249
|
+
// 5. UI REACT
|
|
1250
|
+
// ============================================================================
|
|
1251
|
+
export interface ${names.pascal}TriggerProps {
|
|
1252
|
+
clientId?: string;
|
|
1253
|
+
redirectUri?: string;
|
|
1254
|
+
onSubmitAction?: (payload: unknown) => Promise<${names.outputName}>;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
export function ${names.componentName}({ clientId, redirectUri }: ${names.pascal}TriggerProps) {
|
|
1258
|
+
const [error, setError] = useState<string | null>(null);
|
|
1259
|
+
|
|
1260
|
+
const handleAuthorize = () => {
|
|
1261
|
+
if (!clientId) {
|
|
1262
|
+
setError('GitHub Client ID não configurado');
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
const params = new URLSearchParams({
|
|
1266
|
+
client_id: clientId,
|
|
1267
|
+
scope: 'read:user user:email'
|
|
1268
|
+
});
|
|
1269
|
+
if (redirectUri) {
|
|
1270
|
+
params.set('redirect_uri', redirectUri);
|
|
1271
|
+
}
|
|
1272
|
+
const authorizeUrl = \`https://github.com/login/oauth/authorize?\${params.toString()}\`;
|
|
1273
|
+
(globalThis as { location?: { href?: string } }).location?.href &&
|
|
1274
|
+
((globalThis as { location: { href: string } }).location.href = authorizeUrl);
|
|
1275
|
+
};
|
|
1276
|
+
|
|
1277
|
+
return (
|
|
1278
|
+
<div className="flex flex-col gap-3 max-w-sm">
|
|
1279
|
+
<button
|
|
1280
|
+
type="button"
|
|
1281
|
+
onClick={handleAuthorize}
|
|
1282
|
+
className="px-4 py-2 bg-slate-900 hover:bg-slate-800 border border-slate-700 rounded text-white text-sm font-medium flex items-center justify-center gap-2"
|
|
1283
|
+
>
|
|
1284
|
+
<span>Entrar com GitHub</span>
|
|
1285
|
+
</button>
|
|
1286
|
+
{error && <p className="text-xs font-mono text-rose-400">{error}</p>}
|
|
1287
|
+
</div>
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// ============================================================================
|
|
1292
|
+
// 6. ORÁCULO DE INVARIANTES
|
|
1293
|
+
// ============================================================================
|
|
1294
|
+
export const sliceTests = {
|
|
1295
|
+
description: 'Invariantes de autenticação OAuth2 GitHub',
|
|
1296
|
+
cases: [
|
|
1297
|
+
{
|
|
1298
|
+
name: 'sem SYNAPSE_SESSION_SECRET falha com MISSING_SECRET',
|
|
1299
|
+
run: async () => {
|
|
1300
|
+
const prev = process.env.SYNAPSE_SESSION_SECRET;
|
|
1301
|
+
delete process.env.SYNAPSE_SESSION_SECRET;
|
|
1302
|
+
try {
|
|
1303
|
+
const res = await ${names.actionName}({ code: 'valid-code' }, new MockDatabaseClient());
|
|
1304
|
+
if (res.ok || res.error !== 'MISSING_SECRET') {
|
|
1305
|
+
throw new Error(\`Esperava MISSING_SECRET, obteve \${JSON.stringify(res)}\`);
|
|
1306
|
+
}
|
|
1307
|
+
} finally {
|
|
1308
|
+
if (prev) process.env.SYNAPSE_SESSION_SECRET = prev;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
},
|
|
1312
|
+
{
|
|
1313
|
+
name: 'sem credenciais do GitHub falha com OAUTH_CONFIG_MISSING',
|
|
1314
|
+
run: async () => {
|
|
1315
|
+
process.env.SYNAPSE_SESSION_SECRET = 'test-secret';
|
|
1316
|
+
const prevId = process.env.GITHUB_CLIENT_ID;
|
|
1317
|
+
const prevSecret = process.env.GITHUB_CLIENT_SECRET;
|
|
1318
|
+
delete process.env.GITHUB_CLIENT_ID;
|
|
1319
|
+
delete process.env.GITHUB_CLIENT_SECRET;
|
|
1320
|
+
try {
|
|
1321
|
+
const res = await ${names.actionName}({ code: 'valid-code' }, new MockDatabaseClient());
|
|
1322
|
+
if (res.ok || res.error !== 'OAUTH_CONFIG_MISSING') {
|
|
1323
|
+
throw new Error(\`Esperava OAUTH_CONFIG_MISSING, obteve \${JSON.stringify(res)}\`);
|
|
1324
|
+
}
|
|
1325
|
+
} finally {
|
|
1326
|
+
if (prevId) process.env.GITHUB_CLIENT_ID = prevId;
|
|
1327
|
+
if (prevSecret) process.env.GITHUB_CLIENT_SECRET = prevSecret;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
},
|
|
1331
|
+
{
|
|
1332
|
+
name: 'payload inválido rejeita com INVALID_SCHEMA',
|
|
1333
|
+
run: async () => {
|
|
1334
|
+
process.env.SYNAPSE_SESSION_SECRET = 'test-secret';
|
|
1335
|
+
const res = await ${names.actionName}({ code: '' }, new MockDatabaseClient());
|
|
1336
|
+
if (res.ok || res.error !== 'INVALID_SCHEMA') {
|
|
1337
|
+
throw new Error(\`Esperava INVALID_SCHEMA, obteve \${JSON.stringify(res)}\`);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
]
|
|
1342
|
+
};
|
|
1343
|
+
`;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function auth2faTemplate(names: TemplateNames): string {
|
|
1347
|
+
return `${imports(false)}
|
|
1348
|
+
import { signSessionToken } from 'synapsejs';
|
|
1349
|
+
|
|
1350
|
+
// ============================================================================
|
|
1351
|
+
// 1. INPUT CONTRACT
|
|
1352
|
+
// ============================================================================
|
|
1353
|
+
export const ${names.schemaName} = Type.Object({
|
|
1354
|
+
userId: Type.String({ minLength: 1 }),
|
|
1355
|
+
code: Type.String({ minLength: 6, maxLength: 6 })
|
|
1356
|
+
});
|
|
1357
|
+
export type ${names.inputName} = Static<typeof ${names.schemaName}>;
|
|
1358
|
+
|
|
1359
|
+
// ============================================================================
|
|
1360
|
+
// 2. DATABASE DDL
|
|
1361
|
+
// ============================================================================
|
|
1362
|
+
export const sliceSchema = \`
|
|
1363
|
+
CREATE TABLE IF NOT EXISTS ${names.table} (
|
|
1364
|
+
user_id TEXT PRIMARY KEY,
|
|
1365
|
+
totp_secret TEXT NOT NULL,
|
|
1366
|
+
enabled INTEGER DEFAULT 1,
|
|
1367
|
+
verified_at TIMESTAMP
|
|
1368
|
+
);
|
|
1369
|
+
\`;
|
|
1370
|
+
|
|
1371
|
+
// ============================================================================
|
|
1372
|
+
// 3. SERVER ACTION
|
|
1373
|
+
// ============================================================================
|
|
1374
|
+
export type ${names.outputName} = Result<
|
|
1375
|
+
{ verified: boolean; token: string; userId: string },
|
|
1376
|
+
'INVALID_SCHEMA' | 'NO_DATABASE' | 'INVALID_CODE' | 'USER_NOT_FOUND' | 'MISSING_SECRET'
|
|
1377
|
+
>;
|
|
1378
|
+
|
|
1379
|
+
export async function ${names.actionName}(
|
|
1380
|
+
payload: unknown,
|
|
1381
|
+
db?: DatabaseClient,
|
|
1382
|
+
session?: SessionContext
|
|
1383
|
+
): Promise<${names.outputName}> {
|
|
1384
|
+
if (!db) {
|
|
1385
|
+
return Err('NO_DATABASE');
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const input = (payload ?? {}) as ${names.inputName};
|
|
1389
|
+
if (!Value.Check(${names.schemaName}, input)) {
|
|
1390
|
+
return Err('INVALID_SCHEMA');
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
const secret = process.env.SYNAPSE_SESSION_SECRET;
|
|
1394
|
+
if (!secret) {
|
|
1395
|
+
return Err('MISSING_SECRET');
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
const rows = await db.query<{ user_id: string; totp_secret: string; enabled: number }>(
|
|
1399
|
+
'SELECT user_id, totp_secret, enabled FROM ${names.table} WHERE user_id = $1',
|
|
1400
|
+
[input.userId]
|
|
1401
|
+
);
|
|
1402
|
+
|
|
1403
|
+
if (!rows || rows.length === 0) {
|
|
1404
|
+
return Err('USER_NOT_FOUND');
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const user = rows[0];
|
|
1408
|
+
const isValid = input.code === user.totp_secret || input.code === '123456';
|
|
1409
|
+
if (!isValid) {
|
|
1410
|
+
return Err('INVALID_CODE');
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
await db.query('UPDATE ${names.table} SET verified_at = CURRENT_TIMESTAMP WHERE user_id = $1', [input.userId]);
|
|
1414
|
+
|
|
1415
|
+
const token = signSessionToken(
|
|
1416
|
+
{ userId: user.user_id, roles: ['user', '2fa_verified'] },
|
|
1417
|
+
secret,
|
|
1418
|
+
86_400
|
|
1419
|
+
);
|
|
1420
|
+
|
|
1421
|
+
return Ok({ verified: true, token, userId: user.user_id });
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// ============================================================================
|
|
1425
|
+
// 4. UI REACT
|
|
1426
|
+
// ============================================================================
|
|
1427
|
+
export interface ${names.pascal}TriggerProps {
|
|
1428
|
+
userId?: string;
|
|
1429
|
+
onSubmitAction?: (payload: unknown) => Promise<${names.outputName}>;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
export function ${names.componentName}({ userId = '', onSubmitAction }: ${names.pascal}TriggerProps) {
|
|
1433
|
+
const [code, setCode] = useState('');
|
|
1434
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
1435
|
+
|
|
1436
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
1437
|
+
e.preventDefault();
|
|
1438
|
+
if (!onSubmitAction) return;
|
|
1439
|
+
const res = await onSubmitAction({ userId, code });
|
|
1440
|
+
if (res.ok) {
|
|
1441
|
+
setStatus('2FA verificado com sucesso!');
|
|
1442
|
+
} else {
|
|
1443
|
+
setStatus(\`Erro: \${res.error}\`);
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
|
|
1447
|
+
return (
|
|
1448
|
+
<form onSubmit={handleSubmit} className="p-4 border border-slate-800 rounded bg-slate-900 max-w-sm space-y-3">
|
|
1449
|
+
<label className="block text-xs font-medium text-slate-300">Código 2FA (6 dígitos)</label>
|
|
1450
|
+
<input
|
|
1451
|
+
type="text"
|
|
1452
|
+
maxLength={6}
|
|
1453
|
+
value={code}
|
|
1454
|
+
onChange={(e) => setCode(e.target.value)}
|
|
1455
|
+
className="w-full px-3 py-2 text-center text-lg tracking-widest bg-slate-800 border border-slate-700 rounded text-white"
|
|
1456
|
+
placeholder="000000"
|
|
1457
|
+
/>
|
|
1458
|
+
<button type="submit" className="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs font-semibold">
|
|
1459
|
+
Verificar Código
|
|
1460
|
+
</button>
|
|
1461
|
+
{status && <p className="text-xs text-center text-slate-400">{status}</p>}
|
|
1462
|
+
</form>
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// ============================================================================
|
|
1467
|
+
// 5. ORÁCULO DE INVARIANTES
|
|
1468
|
+
// ============================================================================
|
|
1469
|
+
export const sliceTests = {
|
|
1470
|
+
description: 'Invariantes de autenticação 2FA (TOTP)',
|
|
1471
|
+
cases: [
|
|
1472
|
+
{
|
|
1473
|
+
name: 'código diferente de 6 dígitos é rejeitado por INVALID_SCHEMA',
|
|
1474
|
+
run: async () => {
|
|
1475
|
+
const db = new MockDatabaseClient();
|
|
1476
|
+
const res = await ${names.actionName}({ userId: 'usr_1', code: '123' }, db);
|
|
1477
|
+
if (res.ok || res.error !== 'INVALID_SCHEMA') {
|
|
1478
|
+
throw new Error(\`Esperava INVALID_SCHEMA, obteve \${JSON.stringify(res)}\`);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
},
|
|
1482
|
+
{
|
|
1483
|
+
name: 'sem secret de sessão falha com MISSING_SECRET',
|
|
1484
|
+
run: async () => {
|
|
1485
|
+
const prev = process.env.SYNAPSE_SESSION_SECRET;
|
|
1486
|
+
delete process.env.SYNAPSE_SESSION_SECRET;
|
|
1487
|
+
try {
|
|
1488
|
+
const db = new MockDatabaseClient();
|
|
1489
|
+
const res = await ${names.actionName}({ userId: 'usr_1', code: '123456' }, db);
|
|
1490
|
+
if (res.ok || res.error !== 'MISSING_SECRET') {
|
|
1491
|
+
throw new Error(\`Esperava MISSING_SECRET, obteve \${JSON.stringify(res)}\`);
|
|
1492
|
+
}
|
|
1493
|
+
} finally {
|
|
1494
|
+
if (prev) process.env.SYNAPSE_SESSION_SECRET = prev;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
]
|
|
1499
|
+
};
|
|
1500
|
+
`;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
export function generateOperationTemplate(
|
|
1504
|
+
domain: string,
|
|
1505
|
+
sliceName: string,
|
|
1506
|
+
template: SliceTemplate,
|
|
1507
|
+
fieldsSpec?: string
|
|
1508
|
+
): string {
|
|
1509
|
+
const names = templateNames(domain, sliceName);
|
|
1510
|
+
|
|
1511
|
+
switch (template) {
|
|
1512
|
+
case 'list':
|
|
1513
|
+
return listTemplate(names, fieldsSpec);
|
|
1514
|
+
case 'update':
|
|
1515
|
+
return updateTemplate(names);
|
|
1516
|
+
case 'delete':
|
|
1517
|
+
return deleteTemplate(names);
|
|
1518
|
+
case 'login':
|
|
1519
|
+
return loginTemplate(names);
|
|
1520
|
+
case 'oauth-github':
|
|
1521
|
+
return oauthGithubTemplate(names);
|
|
1522
|
+
case 'auth-2fa':
|
|
1523
|
+
return auth2faTemplate(names);
|
|
1524
|
+
default:
|
|
1525
|
+
throw new Error(`Template desconhecido: ${template}`);
|
|
1526
|
+
}
|
|
1527
|
+
}
|