@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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/bin/synapse.ts +919 -0
  4. package/package.json +70 -0
  5. package/src/client/components.tsx +508 -0
  6. package/src/client/context.tsx +62 -0
  7. package/src/client/hooks.ts +152 -0
  8. package/src/client/i18n.ts +40 -0
  9. package/src/client.ts +66 -0
  10. package/src/compiler/agent-diagnostic-json.ts +113 -0
  11. package/src/compiler/ast-daemon-compressor.ts +190 -0
  12. package/src/compiler/client-bundler.ts +356 -0
  13. package/src/compiler/context-bench.ts +191 -0
  14. package/src/compiler/contract.ts +260 -0
  15. package/src/compiler/db-schema-generator.ts +306 -0
  16. package/src/compiler/fields-parser.ts +267 -0
  17. package/src/compiler/impact-analyzer.ts +279 -0
  18. package/src/compiler/migration-runner.ts +555 -0
  19. package/src/compiler/oracle-runner.ts +293 -0
  20. package/src/compiler/scaffolder.ts +528 -0
  21. package/src/compiler/schema-dag.ts +137 -0
  22. package/src/compiler/schema-drift.ts +184 -0
  23. package/src/compiler/slice-discovery.ts +154 -0
  24. package/src/compiler/slice-splitter.ts +838 -0
  25. package/src/compiler/slice-templates.ts +1527 -0
  26. package/src/compiler/standalone-builder.ts +117 -0
  27. package/src/core/action-context.ts +152 -0
  28. package/src/core/config.ts +77 -0
  29. package/src/core/database-client.ts +246 -0
  30. package/src/core/database-factory.ts +46 -0
  31. package/src/core/image-optimizer.ts +83 -0
  32. package/src/core/index.ts +14 -0
  33. package/src/core/jobs.ts +53 -0
  34. package/src/core/machine-types.ts +75 -0
  35. package/src/core/oracle.ts +20 -0
  36. package/src/core/postgres-client.ts +164 -0
  37. package/src/core/postgres-queue.ts +288 -0
  38. package/src/core/query-builder.ts +343 -0
  39. package/src/core/rpc-client.ts +70 -0
  40. package/src/core/session-context.ts +87 -0
  41. package/src/core/session-cookie.ts +81 -0
  42. package/src/core/session-token.ts +170 -0
  43. package/src/core/sqlite-client.ts +201 -0
  44. package/src/core/storage.ts +317 -0
  45. package/src/index.ts +133 -0
  46. package/src/mcp/server.ts +553 -0
  47. package/src/runtime/client-entry.ts +65 -0
  48. package/src/runtime/discovery-rules.ts +63 -0
  49. package/src/runtime/event-hub.ts +97 -0
  50. package/src/runtime/postgres-event-hub.ts +148 -0
  51. package/src/runtime/queue-engine.ts +255 -0
  52. package/src/runtime/rate-limiter.ts +152 -0
  53. package/src/runtime/server.ts +1550 -0
  54. package/src/runtime/uploads.ts +192 -0
  55. package/templates/starter/.dockerignore +7 -0
  56. package/templates/starter/Dockerfile +40 -0
  57. package/templates/starter/README.md +40 -0
  58. package/templates/starter/gitignore +6 -0
  59. package/templates/starter/package.json +34 -0
  60. package/templates/starter/src/slices/welcome/hello-world.slice.tsx +172 -0
  61. 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
+ }