@ismaelsoilet/synapsejs 1.8.0 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -6
- package/bin/synapse.ts +291 -241
- package/package.json +2 -2
- package/src/client/components.tsx +459 -1
- package/src/client/context.tsx +23 -3
- package/src/client/error-boundary.tsx +59 -0
- package/src/client/hooks.ts +100 -1
- package/src/client/toast.tsx +219 -0
- package/src/client/upload.ts +153 -0
- package/src/client.ts +37 -2
- package/src/compiler/client-bundler.ts +94 -2
- package/src/compiler/context-bench.ts +9 -1
- package/src/compiler/contract.ts +31 -4
- package/src/compiler/db-schema-generator.ts +39 -8
- package/src/compiler/fields-parser.ts +28 -1
- package/src/compiler/impact-analyzer.ts +31 -1
- package/src/compiler/migration-runner.ts +10 -2
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +18 -4
- package/src/compiler/scaffolder.ts +227 -9
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +68 -6
- package/src/compiler/slice-templates.ts +234 -10
- package/src/compiler/typescript-api.ts +33 -0
- package/src/core/action-context.ts +101 -15
- package/src/core/config.ts +88 -10
- package/src/core/cron-parser.ts +167 -0
- package/src/core/database-client.ts +103 -6
- package/src/core/index.ts +5 -0
- package/src/core/jobs.ts +37 -0
- package/src/core/oauth-client.ts +322 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-client.ts +37 -0
- package/src/core/postgres-queue.ts +76 -9
- package/src/core/query-builder.ts +166 -34
- package/src/core/session-context.ts +45 -4
- package/src/core/session-token.ts +124 -8
- package/src/core/sqlite-client.ts +20 -0
- package/src/core/storage.ts +60 -19
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/index.ts +2 -1
- package/src/mcp/server.ts +317 -218
- package/src/mcp/tools.ts +329 -0
- package/src/project-status.ts +433 -0
- package/src/runtime/client-entry.ts +11 -5
- package/src/runtime/network-guard.ts +3 -1
- package/src/runtime/queue-engine.ts +167 -5
- package/src/runtime/rate-limiter.ts +14 -4
- package/src/runtime/security-headers.ts +35 -0
- package/src/runtime/server.ts +1174 -236
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +9 -2
- package/templates/starter/package.json +2 -2
- package/templates/starter/tsconfig.json +1 -0
|
@@ -12,7 +12,15 @@
|
|
|
12
12
|
|
|
13
13
|
import { generateTableColumnsCode, parseFields } from './fields-parser';
|
|
14
14
|
|
|
15
|
-
export type SliceTemplate =
|
|
15
|
+
export type SliceTemplate =
|
|
16
|
+
| 'create'
|
|
17
|
+
| 'list'
|
|
18
|
+
| 'update'
|
|
19
|
+
| 'delete'
|
|
20
|
+
| 'login'
|
|
21
|
+
| 'oauth-github'
|
|
22
|
+
| 'auth-2fa'
|
|
23
|
+
| 'modal-crud';
|
|
16
24
|
export const SLICE_TEMPLATES: SliceTemplate[] = [
|
|
17
25
|
'create',
|
|
18
26
|
'list',
|
|
@@ -20,7 +28,8 @@ export const SLICE_TEMPLATES: SliceTemplate[] = [
|
|
|
20
28
|
'delete',
|
|
21
29
|
'login',
|
|
22
30
|
'oauth-github',
|
|
23
|
-
'auth-2fa'
|
|
31
|
+
'auth-2fa',
|
|
32
|
+
'modal-crud'
|
|
24
33
|
];
|
|
25
34
|
|
|
26
35
|
/** Paginação das listas geradas: o agente não escolhe esses números por conta. */
|
|
@@ -868,8 +877,8 @@ export async function ${names.actionName}(
|
|
|
868
877
|
const input = payload as ${names.inputName};
|
|
869
878
|
const email = input.email.trim().toLowerCase();
|
|
870
879
|
|
|
871
|
-
const rows = await db.query<{ id: string; password_hash: string; roles: string }>(
|
|
872
|
-
'SELECT
|
|
880
|
+
const rows = await db.query<{ id: string; password_hash: string; roles: string; permissions?: string }>(
|
|
881
|
+
'SELECT * FROM users WHERE email = $1',
|
|
873
882
|
[email]
|
|
874
883
|
);
|
|
875
884
|
const user = rows[0];
|
|
@@ -884,10 +893,15 @@ export async function ${names.actionName}(
|
|
|
884
893
|
.map((role) => role.trim())
|
|
885
894
|
.filter((role) => role.length > 0);
|
|
886
895
|
|
|
896
|
+
const permissions = (user.permissions ?? '')
|
|
897
|
+
.split(',')
|
|
898
|
+
.map((permission) => permission.trim())
|
|
899
|
+
.filter((permission) => permission.length > 0);
|
|
900
|
+
|
|
887
901
|
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_MAX_AGE_SECONDS;
|
|
888
902
|
|
|
889
903
|
return Ok({
|
|
890
|
-
token: signSessionToken({ userId: user.id, roles }, secret, SESSION_MAX_AGE_SECONDS),
|
|
904
|
+
token: signSessionToken({ userId: user.id, roles, permissions }, secret, SESSION_MAX_AGE_SECONDS),
|
|
891
905
|
roles,
|
|
892
906
|
expiresAt
|
|
893
907
|
});
|
|
@@ -971,8 +985,8 @@ export const sliceTests = {
|
|
|
971
985
|
run: async () => {
|
|
972
986
|
process.env.SYNAPSE_SESSION_SECRET = 'segredo-de-teste';
|
|
973
987
|
const hash = await Bun.password.hash('senha-com-oito');
|
|
974
|
-
const db = new MockDatabaseClient().onQuery(/SELECT
|
|
975
|
-
{ id: 'user-1', password_hash: hash, roles: 'admin,billing' }
|
|
988
|
+
const db = new MockDatabaseClient().onQuery(/SELECT .* FROM users/, () => [
|
|
989
|
+
{ id: 'user-1', password_hash: hash, roles: 'admin,billing', permissions: 'orders:read' }
|
|
976
990
|
]);
|
|
977
991
|
|
|
978
992
|
const result = await ${names.actionName}({ email: ' Admin@Loja.com ', password: 'senha-com-oito' }, db);
|
|
@@ -985,7 +999,11 @@ export const sliceTests = {
|
|
|
985
999
|
if (!verified.ok) {
|
|
986
1000
|
throw new Error(\`o servidor recusaria o token emitido: \${verified.error}\`);
|
|
987
1001
|
}
|
|
988
|
-
if (
|
|
1002
|
+
if (
|
|
1003
|
+
verified.value.userId !== 'user-1' ||
|
|
1004
|
+
!verified.value.roles.includes('billing') ||
|
|
1005
|
+
!verified.value.permissions?.includes('orders:read')
|
|
1006
|
+
) {
|
|
989
1007
|
throw new Error(\`claims inesperadas: \${JSON.stringify(verified.value)}\`);
|
|
990
1008
|
}
|
|
991
1009
|
if (result.value.roles.join(',') !== 'admin,billing') {
|
|
@@ -998,7 +1016,7 @@ export const sliceTests = {
|
|
|
998
1016
|
run: async () => {
|
|
999
1017
|
process.env.SYNAPSE_SESSION_SECRET = 'segredo-de-teste';
|
|
1000
1018
|
const hash = await Bun.password.hash('senha-com-oito');
|
|
1001
|
-
const db = new MockDatabaseClient().onQuery(/SELECT
|
|
1019
|
+
const db = new MockDatabaseClient().onQuery(/SELECT .* FROM users/, () => [
|
|
1002
1020
|
{ id: 'user-1', password_hash: hash, roles: 'user' }
|
|
1003
1021
|
]);
|
|
1004
1022
|
|
|
@@ -1013,7 +1031,7 @@ export const sliceTests = {
|
|
|
1013
1031
|
name: 'usuário inexistente retorna INVALID_CREDENTIALS sem revelar que não existe',
|
|
1014
1032
|
run: async () => {
|
|
1015
1033
|
process.env.SYNAPSE_SESSION_SECRET = 'segredo-de-teste';
|
|
1016
|
-
const db = new MockDatabaseClient().onQuery(/SELECT
|
|
1034
|
+
const db = new MockDatabaseClient().onQuery(/SELECT .* FROM users/, () => []);
|
|
1017
1035
|
|
|
1018
1036
|
const result = await ${names.actionName}({ email: 'ninguem@b.com', password: 'senha-com-oito' }, db);
|
|
1019
1037
|
|
|
@@ -1510,6 +1528,210 @@ export const sliceTests = {
|
|
|
1510
1528
|
`;
|
|
1511
1529
|
}
|
|
1512
1530
|
|
|
1531
|
+
function modalCrudTemplate(names: TemplateNames, fieldsSpec?: string): string {
|
|
1532
|
+
const fields = parseFields(fieldsSpec || 'name:string,description:string,status:enum(active|archived)');
|
|
1533
|
+
const ddlColumns = generateTableColumnsCode(fields);
|
|
1534
|
+
const tbProps = fields.map((f) => ` ${f.name}: Type.String()`).join(',\n');
|
|
1535
|
+
const formFields = fields
|
|
1536
|
+
.map((f) => `{ name: '${f.name}', label: '${f.name}', type: 'text', required: true }`)
|
|
1537
|
+
.join(', ');
|
|
1538
|
+
|
|
1539
|
+
return `import React, { useState } from 'react';
|
|
1540
|
+
import { Type, Static } from '@sinclair/typebox';
|
|
1541
|
+
import { Value } from '@sinclair/typebox/value';
|
|
1542
|
+
import * as fc from 'fast-check';
|
|
1543
|
+
import {
|
|
1544
|
+
type DatabaseClient,
|
|
1545
|
+
Err,
|
|
1546
|
+
MockDatabaseClient,
|
|
1547
|
+
Ok,
|
|
1548
|
+
type Result,
|
|
1549
|
+
type SessionContext,
|
|
1550
|
+
requireAuth
|
|
1551
|
+
} from 'synapsejs';
|
|
1552
|
+
import {
|
|
1553
|
+
Button,
|
|
1554
|
+
Card,
|
|
1555
|
+
DataForm,
|
|
1556
|
+
DataTable,
|
|
1557
|
+
Drawer,
|
|
1558
|
+
type FormField,
|
|
1559
|
+
Modal,
|
|
1560
|
+
useToast
|
|
1561
|
+
} from 'synapsejs/client';
|
|
1562
|
+
|
|
1563
|
+
// ============================================================================
|
|
1564
|
+
// 1. CONTRATO DE ENTRADA (TypeBox)
|
|
1565
|
+
// ============================================================================
|
|
1566
|
+
export const ${names.schemaName} = Type.Object({
|
|
1567
|
+
${tbProps}
|
|
1568
|
+
}, { additionalProperties: true });
|
|
1569
|
+
export type ${names.inputName} = Static<typeof ${names.schemaName}>;
|
|
1570
|
+
|
|
1571
|
+
// ============================================================================
|
|
1572
|
+
// 2. DDL SCHEMA DECLARATIVO
|
|
1573
|
+
// ============================================================================
|
|
1574
|
+
export const sliceSchema = \`
|
|
1575
|
+
-- up:
|
|
1576
|
+
CREATE TABLE IF NOT EXISTS ${names.table} (
|
|
1577
|
+
id TEXT PRIMARY KEY,
|
|
1578
|
+
${ddlColumns},
|
|
1579
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
1580
|
+
);
|
|
1581
|
+
-- down:
|
|
1582
|
+
DROP TABLE IF EXISTS ${names.table};
|
|
1583
|
+
\`;
|
|
1584
|
+
|
|
1585
|
+
// ============================================================================
|
|
1586
|
+
// 3. MODELAGEM DE DOMÍNIO
|
|
1587
|
+
// ============================================================================
|
|
1588
|
+
export interface ${names.pascal}Row {
|
|
1589
|
+
id: string;
|
|
1590
|
+
name: string;
|
|
1591
|
+
[key: string]: unknown;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
export type ${names.outputName} = Result<
|
|
1595
|
+
${names.pascal}Row,
|
|
1596
|
+
'INVALID_SCHEMA' | 'NO_DATABASE' | 'UNAUTHORIZED'
|
|
1597
|
+
>;
|
|
1598
|
+
|
|
1599
|
+
// ============================================================================
|
|
1600
|
+
// 4. SERVER ACTION
|
|
1601
|
+
// ============================================================================
|
|
1602
|
+
export async function ${names.actionName}(
|
|
1603
|
+
payload: unknown,
|
|
1604
|
+
db?: DatabaseClient,
|
|
1605
|
+
session?: SessionContext
|
|
1606
|
+
): Promise<${names.outputName}> {
|
|
1607
|
+
if (!db) return Err('NO_DATABASE');
|
|
1608
|
+
if (!Value.Check(${names.schemaName}, payload)) return Err('INVALID_SCHEMA');
|
|
1609
|
+
|
|
1610
|
+
const id = crypto.randomUUID();
|
|
1611
|
+
const record: Record<string, unknown> = {
|
|
1612
|
+
id,
|
|
1613
|
+
...(payload as Record<string, unknown>),
|
|
1614
|
+
created_at: new Date().toISOString()
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1617
|
+
await db.insert('${names.table}', record);
|
|
1618
|
+
return Ok(record as unknown as ${names.pascal}Row);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// ============================================================================
|
|
1622
|
+
// 5. SERVER LOADER (SSR)
|
|
1623
|
+
// ============================================================================
|
|
1624
|
+
export async function ${names.pascal}Loader(context: { db: DatabaseClient }) {
|
|
1625
|
+
const rows = await context.db.findMany<${names.pascal}Row>('${names.table}', { limit: 50 });
|
|
1626
|
+
return { rows };
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// ============================================================================
|
|
1630
|
+
// 6. REACT UI — MODAL CRUD COM DRAWER E TOAST
|
|
1631
|
+
// ============================================================================
|
|
1632
|
+
const FORM_FIELDS: FormField[] = [${formFields}];
|
|
1633
|
+
|
|
1634
|
+
export interface ${names.componentName}Props {
|
|
1635
|
+
rows?: ${names.pascal}Row[];
|
|
1636
|
+
onSubmitAction?: (payload: unknown) => Promise<${names.outputName}>;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
export function ${names.componentName}({ rows = [], onSubmitAction }: ${names.componentName}Props) {
|
|
1640
|
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
1641
|
+
const [selectedItem, setSelectedItem] = useState<${names.pascal}Row | null>(null);
|
|
1642
|
+
const toast = useToast();
|
|
1643
|
+
|
|
1644
|
+
const handleCreate = async (formData: Record<string, unknown>) => {
|
|
1645
|
+
if (!onSubmitAction) return;
|
|
1646
|
+
const res = await onSubmitAction(formData);
|
|
1647
|
+
if (res.ok) {
|
|
1648
|
+
toast.success('Registro criado com sucesso!', '${names.pascal}');
|
|
1649
|
+
setIsModalOpen(false);
|
|
1650
|
+
} else {
|
|
1651
|
+
toast.error('Erro ao salvar: ' + res.error);
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
|
|
1655
|
+
return (
|
|
1656
|
+
<div className="space-y-6">
|
|
1657
|
+
<div className="flex items-center justify-between">
|
|
1658
|
+
<div>
|
|
1659
|
+
<h2 className="text-xl font-bold text-white tracking-tight">${names.pascal}</h2>
|
|
1660
|
+
<p className="text-xs text-slate-400">Gerenciado com Modal e Drawer integrados</p>
|
|
1661
|
+
</div>
|
|
1662
|
+
<Button onClick={() => setIsModalOpen(true)}>➕ Novo Registro</Button>
|
|
1663
|
+
</div>
|
|
1664
|
+
|
|
1665
|
+
<Card>
|
|
1666
|
+
<DataTable
|
|
1667
|
+
data={rows}
|
|
1668
|
+
columns={[
|
|
1669
|
+
{ key: 'id', header: 'ID', render: (val) => <span className="font-mono text-xs">{String(val).slice(0, 8)}...</span> },
|
|
1670
|
+
{ key: 'name', header: 'Nome' },
|
|
1671
|
+
{
|
|
1672
|
+
key: 'actions',
|
|
1673
|
+
header: 'Ações',
|
|
1674
|
+
render: (_, row) => (
|
|
1675
|
+
<Button size="sm" variant="secondary" onClick={() => setSelectedItem(row)}>
|
|
1676
|
+
Ver Detalhes
|
|
1677
|
+
</Button>
|
|
1678
|
+
)
|
|
1679
|
+
}
|
|
1680
|
+
]}
|
|
1681
|
+
/>
|
|
1682
|
+
</Card>
|
|
1683
|
+
|
|
1684
|
+
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} title="Cadastrar ${names.pascal}">
|
|
1685
|
+
<DataForm fields={FORM_FIELDS} onSubmit={handleCreate} submitLabel="Salvar" />
|
|
1686
|
+
</Modal>
|
|
1687
|
+
|
|
1688
|
+
<Drawer
|
|
1689
|
+
isOpen={Boolean(selectedItem)}
|
|
1690
|
+
onClose={() => setSelectedItem(null)}
|
|
1691
|
+
title="Detalhes do Registro"
|
|
1692
|
+
position="right"
|
|
1693
|
+
>
|
|
1694
|
+
{selectedItem && (
|
|
1695
|
+
<div className="space-y-4 text-xs font-mono">
|
|
1696
|
+
<pre className="p-4 bg-slate-950 rounded-xl border border-slate-800 text-slate-300 overflow-x-auto">
|
|
1697
|
+
{JSON.stringify(selectedItem, null, 2)}
|
|
1698
|
+
</pre>
|
|
1699
|
+
</div>
|
|
1700
|
+
)}
|
|
1701
|
+
</Drawer>
|
|
1702
|
+
</div>
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// ============================================================================
|
|
1707
|
+
// 7. ORÁCULO DE TESTES (PBT)
|
|
1708
|
+
// ============================================================================
|
|
1709
|
+
export const sliceTests = {
|
|
1710
|
+
description: 'Garantias invariantes do ${names.pascal} Modal CRUD',
|
|
1711
|
+
cases: [
|
|
1712
|
+
{
|
|
1713
|
+
name: 'payload inválido retorna INVALID_SCHEMA',
|
|
1714
|
+
run: async () => {
|
|
1715
|
+
const res = await ${names.actionName}({ invalidField: 123 }, new MockDatabaseClient());
|
|
1716
|
+
if (res.ok || res.error !== 'INVALID_SCHEMA') {
|
|
1717
|
+
throw new Error('Deveria recusar payload fora do schema');
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
},
|
|
1721
|
+
{
|
|
1722
|
+
name: 'sem conexão de banco retorna NO_DATABASE',
|
|
1723
|
+
run: async () => {
|
|
1724
|
+
const res = await ${names.actionName}({ name: 'Teste' });
|
|
1725
|
+
if (res.ok || res.error !== 'NO_DATABASE') {
|
|
1726
|
+
throw new Error('Deveria acusar NO_DATABASE');
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
]
|
|
1731
|
+
};
|
|
1732
|
+
`;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1513
1735
|
export function generateOperationTemplate(
|
|
1514
1736
|
domain: string,
|
|
1515
1737
|
sliceName: string,
|
|
@@ -1531,6 +1753,8 @@ export function generateOperationTemplate(
|
|
|
1531
1753
|
return oauthGithubTemplate(names);
|
|
1532
1754
|
case 'auth-2fa':
|
|
1533
1755
|
return auth2faTemplate(names);
|
|
1756
|
+
case 'modal-crud':
|
|
1757
|
+
return modalCrudTemplate(names, fieldsSpec);
|
|
1534
1758
|
default:
|
|
1535
1759
|
throw new Error(`Template desconhecido: ${template}`);
|
|
1536
1760
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - TypeScript compiler API guard.
|
|
3
|
+
*
|
|
4
|
+
* The splitter, checker and repo-map generator use the in-process JavaScript
|
|
5
|
+
* compiler API. TypeScript 7 (the native compiler) ships a CLI-only package and
|
|
6
|
+
* removed that API, so resolving it must fail with a named, actionable error
|
|
7
|
+
* instead of a `TypeError` deep inside the compiler.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as ts from 'typescript';
|
|
11
|
+
|
|
12
|
+
export const COMPILER_API_UNAVAILABLE = 'COMPILER_API_UNAVAILABLE';
|
|
13
|
+
|
|
14
|
+
interface CompilerModuleShape {
|
|
15
|
+
version?: string;
|
|
16
|
+
sys?: { readFile?: unknown };
|
|
17
|
+
createProgram?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function assertCompilerApi(module: unknown = ts): void {
|
|
21
|
+
const candidate = module as CompilerModuleShape;
|
|
22
|
+
|
|
23
|
+
if (typeof candidate?.sys?.readFile === 'function' && typeof candidate.createProgram === 'function') {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const error = new Error(
|
|
28
|
+
`${COMPILER_API_UNAVAILABLE}: TypeScript ${candidate?.version ?? 'unknown'} does not expose the in-process compiler API required by SynapseJS. Use TypeScript 5.9.x (the pinned version) or run the compiler through its CLI.`
|
|
29
|
+
) as Error & { code: string };
|
|
30
|
+
|
|
31
|
+
error.code = COMPILER_API_UNAVAILABLE;
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
@@ -10,6 +10,7 @@ import { getEventHub } from '../runtime/event-hub';
|
|
|
10
10
|
import type { DatabaseClient, QueryOptions, WhereCondition } from './database-client';
|
|
11
11
|
import type { SessionContext } from './session-context';
|
|
12
12
|
import { getStorage, type StorageClient } from './storage';
|
|
13
|
+
import { declaredTopic, scopedTopic } from './topics';
|
|
13
14
|
|
|
14
15
|
export interface StructuredLogger {
|
|
15
16
|
info(message: string, context?: Record<string, unknown>): void;
|
|
@@ -20,6 +21,8 @@ export interface StructuredLogger {
|
|
|
20
21
|
|
|
21
22
|
export interface JobEnqueueOptions {
|
|
22
23
|
delaySeconds?: number;
|
|
24
|
+
/** Absolute execution timestamp in milliseconds. Mutually exclusive with delaySeconds. */
|
|
25
|
+
runAt?: number;
|
|
23
26
|
priority?: number;
|
|
24
27
|
}
|
|
25
28
|
|
|
@@ -32,8 +35,20 @@ export type EnqueueFn = <TPayload>(
|
|
|
32
35
|
export type BroadcastFn = (topic: string, data: unknown) => number;
|
|
33
36
|
|
|
34
37
|
export interface CookieOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Defaults to true. A cookie written without stating otherwise is not readable by
|
|
40
|
+
* script; pass `false` explicitly to opt out.
|
|
41
|
+
*/
|
|
35
42
|
httpOnly?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Defaults to true. A cookie written without stating otherwise is not sent over
|
|
45
|
+
* plaintext; pass `false` explicitly to opt out.
|
|
46
|
+
*/
|
|
36
47
|
secure?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Defaults to `lax`. An explicit `strict` or `none` applies instead — the framework
|
|
50
|
+
* never infers a weaker policy from anything but a deliberate statement.
|
|
51
|
+
*/
|
|
37
52
|
sameSite?: 'lax' | 'strict' | 'none';
|
|
38
53
|
maxAge?: number;
|
|
39
54
|
path?: string;
|
|
@@ -46,6 +61,12 @@ export interface PendingCookie {
|
|
|
46
61
|
options?: CookieOptions;
|
|
47
62
|
}
|
|
48
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Serializes one response cookie. The safe attributes are the **defaults**: a cookie
|
|
66
|
+
* written with no options at all is HttpOnly, Secure and SameSite=Lax, and only an
|
|
67
|
+
* explicit `false` (or a different `sameSite`) changes that. This is a breaking
|
|
68
|
+
* change from the previous opt-in behaviour.
|
|
69
|
+
*/
|
|
49
70
|
export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
|
|
50
71
|
const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
|
|
51
72
|
if (options.maxAge !== undefined) {
|
|
@@ -56,15 +77,14 @@ export function serializeCookie(name: string, value: string, options: CookieOpti
|
|
|
56
77
|
}
|
|
57
78
|
const cleanPath = options.path && !/[\r\n;\s]/.test(options.path) ? options.path.trim() : '/';
|
|
58
79
|
parts.push(`Path=${cleanPath}`);
|
|
59
|
-
if (options.secure) {
|
|
80
|
+
if (options.secure ?? true) {
|
|
60
81
|
parts.push('Secure');
|
|
61
82
|
}
|
|
62
|
-
if (options.httpOnly) {
|
|
83
|
+
if (options.httpOnly ?? true) {
|
|
63
84
|
parts.push('HttpOnly');
|
|
64
85
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
86
|
+
const sameSite = options.sameSite ?? 'lax';
|
|
87
|
+
parts.push(`SameSite=${sameSite.charAt(0).toUpperCase() + sameSite.slice(1).toLowerCase()}`);
|
|
68
88
|
return parts.join('; ');
|
|
69
89
|
}
|
|
70
90
|
|
|
@@ -99,11 +119,14 @@ export interface ActionContext<TServices = Record<string, unknown>> extends Data
|
|
|
99
119
|
/** Sets an HTTP response cookie (e.g. HttpOnly session token) */
|
|
100
120
|
readonly setCookie: (name: string, value: string, options?: CookieOptions) => void;
|
|
101
121
|
|
|
122
|
+
/** Emits an HTTP chunked or SSE streaming response for real-time AI/LLM tokens */
|
|
123
|
+
readonly stream: (
|
|
124
|
+
generator: AsyncIterable<string | Uint8Array> | (() => AsyncGenerator<string | Uint8Array>),
|
|
125
|
+
options?: { contentType?: string; status?: number; headers?: Record<string, string> }
|
|
126
|
+
) => Response;
|
|
127
|
+
|
|
102
128
|
/** Internal list of pending cookies to be set on the response */
|
|
103
129
|
readonly _pendingCookies?: PendingCookie[];
|
|
104
|
-
|
|
105
|
-
/** Lazy adapter for Kysely (if configured or requested) */
|
|
106
|
-
readonly kysely?: unknown;
|
|
107
130
|
}
|
|
108
131
|
|
|
109
132
|
export function createDefaultLogger(sliceName?: string): StructuredLogger {
|
|
@@ -130,7 +153,11 @@ export interface ActionContextOptions<TServices = Record<string, unknown>> {
|
|
|
130
153
|
storage?: StorageClient;
|
|
131
154
|
broadcast?: BroadcastFn;
|
|
132
155
|
invalidateCache?: (tags?: string[]) => void;
|
|
133
|
-
|
|
156
|
+
/**
|
|
157
|
+
* The slice key (`<domain>/<name>`) this context belongs to. Publishing into a
|
|
158
|
+
* declared topic is only allowed for the declaration's owner.
|
|
159
|
+
*/
|
|
160
|
+
sliceOwner?: string;
|
|
134
161
|
}
|
|
135
162
|
|
|
136
163
|
/**
|
|
@@ -143,21 +170,77 @@ export function createActionContext<TServices = Record<string, unknown>>(
|
|
|
143
170
|
const {
|
|
144
171
|
db,
|
|
145
172
|
session,
|
|
146
|
-
tenantId = session
|
|
173
|
+
tenantId = session?.tenantId,
|
|
147
174
|
services = {} as TServices,
|
|
148
175
|
logger = createDefaultLogger(),
|
|
149
176
|
enqueue = async () => 'job_noop',
|
|
150
177
|
storage = getStorage(),
|
|
151
|
-
broadcast = (topic: string, data: unknown) => getEventHub().publish(topic, data),
|
|
152
178
|
invalidateCache = () => {},
|
|
153
|
-
|
|
179
|
+
sliceOwner
|
|
154
180
|
} = options;
|
|
155
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Publishing is declared, not inferred: the topic must exist in the session's
|
|
184
|
+
* tenant scope and this feature must be its declared owner. Anything else is
|
|
185
|
+
* rejected and notifies nobody.
|
|
186
|
+
*/
|
|
187
|
+
const broadcast: BroadcastFn =
|
|
188
|
+
options.broadcast ??
|
|
189
|
+
((topic: string, data: unknown) => {
|
|
190
|
+
const declaration = declaredTopic(topic, tenantId);
|
|
191
|
+
|
|
192
|
+
if (!declaration || (sliceOwner && declaration.owner !== sliceOwner)) {
|
|
193
|
+
logger.warn(
|
|
194
|
+
`Broadcast recusado no tópico "${topic}": não declarado${sliceOwner ? ` por ${sliceOwner}` : ''} (defineTopic).`
|
|
195
|
+
);
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return getEventHub().publish(scopedTopic(topic, tenantId), data);
|
|
200
|
+
});
|
|
201
|
+
|
|
156
202
|
const pendingCookies: PendingCookie[] = [];
|
|
157
203
|
const setCookie = (name: string, value: string, options?: CookieOptions) => {
|
|
158
204
|
pendingCookies.push({ name, value, options });
|
|
159
205
|
};
|
|
160
206
|
|
|
207
|
+
const stream = (
|
|
208
|
+
generator: AsyncIterable<string | Uint8Array> | (() => AsyncGenerator<string | Uint8Array>),
|
|
209
|
+
streamOptions?: { contentType?: string; status?: number; headers?: Record<string, string> }
|
|
210
|
+
): Response => {
|
|
211
|
+
const iterable = typeof generator === 'function' ? generator() : generator;
|
|
212
|
+
const encoder = new TextEncoder();
|
|
213
|
+
|
|
214
|
+
const readable = new ReadableStream({
|
|
215
|
+
async start(controller) {
|
|
216
|
+
try {
|
|
217
|
+
for await (const chunk of iterable) {
|
|
218
|
+
if (typeof chunk === 'string') {
|
|
219
|
+
controller.enqueue(encoder.encode(chunk));
|
|
220
|
+
} else {
|
|
221
|
+
controller.enqueue(chunk);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
controller.close();
|
|
225
|
+
} catch (err) {
|
|
226
|
+
controller.error(err);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const headers = new Headers({
|
|
232
|
+
'Content-Type': streamOptions?.contentType || 'text/plain; charset=utf-8',
|
|
233
|
+
'Transfer-Encoding': 'chunked',
|
|
234
|
+
'X-Content-Type-Options': 'nosniff',
|
|
235
|
+
...(streamOptions?.headers || {})
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return new Response(readable, {
|
|
239
|
+
status: streamOptions?.status || 200,
|
|
240
|
+
headers
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
|
|
161
244
|
const context: ActionContext<TServices> = {
|
|
162
245
|
// ActionContext properties
|
|
163
246
|
db,
|
|
@@ -170,8 +253,8 @@ export function createActionContext<TServices = Record<string, unknown>>(
|
|
|
170
253
|
broadcast,
|
|
171
254
|
invalidateCache,
|
|
172
255
|
setCookie,
|
|
256
|
+
stream,
|
|
173
257
|
_pendingCookies: pendingCookies,
|
|
174
|
-
kysely,
|
|
175
258
|
|
|
176
259
|
// DatabaseClient proxy methods
|
|
177
260
|
query: <T = unknown>(sql: string, params?: unknown[]) => db.query<T>(sql, params),
|
|
@@ -184,6 +267,9 @@ export function createActionContext<TServices = Record<string, unknown>>(
|
|
|
184
267
|
update: <T = unknown>(table: string, data: Record<string, unknown>, where: WhereCondition) =>
|
|
185
268
|
db.update<T>(table, data, where),
|
|
186
269
|
delete: (table: string, where: WhereCondition) => db.delete(table, where),
|
|
270
|
+
count: (table: string, where?: WhereCondition) => db.count(table, where),
|
|
271
|
+
exists: (table: string, where: WhereCondition) => db.exists(table, where),
|
|
272
|
+
aggregate: <T = Record<string, unknown>>(table: string, options: QueryOptions) => db.aggregate<T>(table, options),
|
|
187
273
|
transaction: async <T>(operation: (tx: DatabaseClient) => Promise<T>): Promise<T> => {
|
|
188
274
|
return db.transaction(async (txDb) => {
|
|
189
275
|
// Create transactional ActionContext preserving services and context
|
|
@@ -195,8 +281,8 @@ export function createActionContext<TServices = Record<string, unknown>>(
|
|
|
195
281
|
logger,
|
|
196
282
|
enqueue,
|
|
197
283
|
storage,
|
|
198
|
-
broadcast,
|
|
199
|
-
|
|
284
|
+
broadcast: options.broadcast,
|
|
285
|
+
sliceOwner
|
|
200
286
|
});
|
|
201
287
|
return operation(txContext);
|
|
202
288
|
});
|