@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,343 @@
1
+ /**
2
+ * SynapseJS - Isomorphic Query Builder & SQL Compiler
3
+ *
4
+ * Compiles declarative filter criteria, sorting, pagination and mutations
5
+ * into parameterized SQL ($1, $2, ...) compatible with both SQLite and PostgreSQL.
6
+ * Immune to SQL injection and free of heavy ORM runtime overhead.
7
+ */
8
+
9
+ export type WhereOperator =
10
+ | '='
11
+ | '!='
12
+ | '<>'
13
+ | '>'
14
+ | '<'
15
+ | '>='
16
+ | '<='
17
+ | 'LIKE'
18
+ | 'ILIKE'
19
+ | 'IN'
20
+ | 'NOT IN'
21
+ | 'IS NULL'
22
+ | 'IS NOT NULL'
23
+ | 'BETWEEN';
24
+
25
+ export interface WhereClauseItem {
26
+ column: string;
27
+ op: WhereOperator;
28
+ value?: unknown;
29
+ }
30
+
31
+ export type WhereCondition = Record<string, unknown> | WhereClauseItem[];
32
+
33
+ export interface JoinClause {
34
+ table: string;
35
+ on: Record<string, string>;
36
+ type?: 'LEFT' | 'INNER' | 'RIGHT';
37
+ select?: string[];
38
+ as?: string;
39
+ }
40
+
41
+ export interface QueryOptions<T = Record<string, unknown>> {
42
+ select?: Array<keyof T | string>;
43
+ where?: WhereCondition;
44
+ join?: JoinClause[];
45
+ orderBy?: Record<string, 'asc' | 'desc' | 'ASC' | 'DESC'> | string;
46
+ limit?: number;
47
+ offset?: number;
48
+ }
49
+
50
+ /** Sanitize SQL identifiers (table names, column names) to avoid injection */
51
+ export function sanitizeIdentifier(ident: string): string {
52
+ const trimmed = ident.trim();
53
+ if (!/^[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)?$/.test(trimmed)) {
54
+ throw new Error(`Invalid SQL identifier: "${ident}"`);
55
+ }
56
+ return trimmed;
57
+ }
58
+
59
+ /** Normalize WhereCondition into an array of WhereClauseItem */
60
+ function normalizeWhere(where?: WhereCondition): WhereClauseItem[] {
61
+ if (!where) return [];
62
+
63
+ if (Array.isArray(where)) {
64
+ return where;
65
+ }
66
+
67
+ const items: WhereClauseItem[] = [];
68
+ for (const [column, value] of Object.entries(where)) {
69
+ if (value === null || value === undefined) {
70
+ items.push({ column, op: 'IS NULL' });
71
+ } else if (Array.isArray(value)) {
72
+ items.push({ column, op: 'IN', value });
73
+ } else {
74
+ items.push({ column, op: '=', value });
75
+ }
76
+ }
77
+ return items;
78
+ }
79
+
80
+ /**
81
+ * Maps flat prefixed columns (e.g. "customer_name") into nested objects ({ customer: { name } })
82
+ */
83
+ export function nestJoinedRow<T>(row: Record<string, unknown>, joins?: JoinClause[]): T {
84
+ if (!joins || joins.length === 0 || !row || typeof row !== 'object') {
85
+ return row as T;
86
+ }
87
+
88
+ const result: Record<string, unknown> = { ...row };
89
+
90
+ for (const j of joins) {
91
+ const alias = j.as || j.table;
92
+ const prefix = `${alias}_`;
93
+ const nestedObj: Record<string, unknown> = {};
94
+ let hasNestedFields = false;
95
+
96
+ for (const [key, value] of Object.entries(row)) {
97
+ if (key.startsWith(prefix)) {
98
+ const fieldName = key.slice(prefix.length);
99
+ nestedObj[fieldName] = value;
100
+ hasNestedFields = true;
101
+ }
102
+ }
103
+
104
+ if (hasNestedFields) {
105
+ result[alias] = nestedObj;
106
+ }
107
+ }
108
+
109
+ return result as T;
110
+ }
111
+
112
+ /**
113
+ * Compiles a SELECT query with parameterized filters, joins, order, limit, and offset.
114
+ */
115
+ export function compileSelect(table: string, options?: QueryOptions): { sql: string; params: unknown[] } {
116
+ const safeTable = sanitizeIdentifier(table);
117
+ const params: unknown[] = [];
118
+
119
+ // 1. SELECT columns
120
+ const selectCols: string[] = [];
121
+ if (options?.select && options.select.length > 0) {
122
+ selectCols.push(
123
+ ...options.select.map((col) => {
124
+ const safeCol = sanitizeIdentifier(String(col));
125
+ if (options?.join && options.join.length > 0 && !safeCol.includes('.')) {
126
+ return `${safeTable}.${safeCol} AS "${safeCol}"`;
127
+ }
128
+ return safeCol;
129
+ })
130
+ );
131
+ } else if (!options?.join || options.join.length === 0) {
132
+ selectCols.push('*');
133
+ } else {
134
+ selectCols.push(`${safeTable}.*`);
135
+ }
136
+
137
+ // Handle joins
138
+ const joinClauses: string[] = [];
139
+ if (options?.join && options.join.length > 0) {
140
+ for (const j of options.join) {
141
+ const joinType = (j.type || 'LEFT').toUpperCase();
142
+ if (!['LEFT', 'INNER', 'RIGHT'].includes(joinType)) {
143
+ throw new Error(`Unsupported JOIN type: "${j.type}"`);
144
+ }
145
+ const joinTable = sanitizeIdentifier(j.table);
146
+ const joinAlias = j.as ? sanitizeIdentifier(j.as) : joinTable;
147
+ const targetTableClause = j.as ? `${joinTable} AS ${joinAlias}` : joinTable;
148
+
149
+ const onConditions: string[] = [];
150
+ for (const [leftCol, rightCol] of Object.entries(j.on)) {
151
+ const left = sanitizeIdentifier(leftCol.includes('.') ? leftCol : `${safeTable}.${leftCol}`);
152
+ const right = sanitizeIdentifier(rightCol.includes('.') ? rightCol : `${joinAlias}.${rightCol}`);
153
+ onConditions.push(`${left} = ${right}`);
154
+ }
155
+
156
+ joinClauses.push(`${joinType} JOIN ${targetTableClause} ON ${onConditions.join(' AND ')}`);
157
+
158
+ if (j.select && j.select.length > 0) {
159
+ for (const col of j.select) {
160
+ const safeCol = sanitizeIdentifier(col);
161
+ const qualifiedCol = col.includes('.') ? safeCol : `${joinAlias}.${safeCol}`;
162
+ const alias = `${joinAlias}_${col.replace('.', '_')}`;
163
+ selectCols.push(`${qualifiedCol} AS "${alias}"`);
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ let sql = `SELECT ${selectCols.join(', ')} FROM ${safeTable}`;
170
+ if (joinClauses.length > 0) {
171
+ sql += ` ${joinClauses.join(' ')}`;
172
+ }
173
+
174
+ // 2. WHERE clause
175
+ const whereItems = normalizeWhere(options?.where);
176
+ if (whereItems.length > 0) {
177
+ const conditions: string[] = [];
178
+
179
+ for (const item of whereItems) {
180
+ const col = sanitizeIdentifier(item.column);
181
+ const op = item.op.toUpperCase() as WhereOperator;
182
+
183
+ if (op === 'IS NULL' || op === 'IS NOT NULL') {
184
+ conditions.push(`${col} ${op}`);
185
+ } else if (op === 'IN' || op === 'NOT IN') {
186
+ const arr = Array.isArray(item.value) ? item.value : [item.value];
187
+ if (arr.length === 0) {
188
+ // Empty IN clause: IN () is invalid SQL -> false / true condition
189
+ conditions.push(op === 'IN' ? '1=0' : '1=1');
190
+ } else {
191
+ const placeholders = arr.map((val) => {
192
+ params.push(val);
193
+ return `$${params.length}`;
194
+ });
195
+ conditions.push(`${col} ${op} (${placeholders.join(', ')})`);
196
+ }
197
+ } else if (op === 'BETWEEN') {
198
+ const arr = Array.isArray(item.value) ? item.value : [];
199
+ if (arr.length !== 2) {
200
+ throw new Error(`BETWEEN operator requires an array of 2 values, received: ${JSON.stringify(item.value)}`);
201
+ }
202
+ params.push(arr[0]);
203
+ const p1 = `$${params.length}`;
204
+ params.push(arr[1]);
205
+ const p2 = `$${params.length}`;
206
+ conditions.push(`${col} BETWEEN ${p1} AND ${p2}`);
207
+ } else {
208
+ params.push(item.value);
209
+ conditions.push(`${col} ${op} $${params.length}`);
210
+ }
211
+ }
212
+
213
+ if (conditions.length > 0) {
214
+ sql += ` WHERE ${conditions.join(' AND ')}`;
215
+ }
216
+ }
217
+
218
+ // 3. ORDER BY clause
219
+ if (options?.orderBy) {
220
+ if (typeof options.orderBy === 'string') {
221
+ const parts = options.orderBy.split(',').map((part) => {
222
+ const [col, dir] = part.trim().split(/\s+/);
223
+ const safeCol = sanitizeIdentifier(col);
224
+ const safeDir = dir && dir.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
225
+ return `${safeCol} ${safeDir}`;
226
+ });
227
+ sql += ` ORDER BY ${parts.join(', ')}`;
228
+ } else {
229
+ const parts = Object.entries(options.orderBy).map(([col, dir]) => {
230
+ const safeCol = sanitizeIdentifier(col);
231
+ const safeDir = String(dir).toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
232
+ return `${safeCol} ${safeDir}`;
233
+ });
234
+ if (parts.length > 0) {
235
+ sql += ` ORDER BY ${parts.join(', ')}`;
236
+ }
237
+ }
238
+ }
239
+
240
+ // 4. LIMIT & OFFSET
241
+ if (typeof options?.limit === 'number' && options.limit >= 0) {
242
+ params.push(options.limit);
243
+ sql += ` LIMIT $${params.length}`;
244
+ }
245
+
246
+ if (typeof options?.offset === 'number' && options.offset >= 0) {
247
+ params.push(options.offset);
248
+ sql += ` OFFSET $${params.length}`;
249
+ }
250
+
251
+ return { sql, params };
252
+ }
253
+
254
+ /**
255
+ * Compiles an INSERT query returning the created row.
256
+ */
257
+ export function compileInsert(table: string, data: Record<string, unknown>): { sql: string; params: unknown[] } {
258
+ const safeTable = sanitizeIdentifier(table);
259
+ const keys = Object.keys(data);
260
+ if (keys.length === 0) {
261
+ throw new Error(`Cannot insert empty record into ${table}`);
262
+ }
263
+
264
+ const columns = keys.map(sanitizeIdentifier);
265
+ const params: unknown[] = [];
266
+ const placeholders = keys.map((key) => {
267
+ params.push(data[key]);
268
+ return `$${params.length}`;
269
+ });
270
+
271
+ const sql = `INSERT INTO ${safeTable} (${columns.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *;`;
272
+ return { sql, params };
273
+ }
274
+
275
+ /**
276
+ * Compiles an UPDATE query returning the modified rows.
277
+ */
278
+ export function compileUpdate(
279
+ table: string,
280
+ data: Record<string, unknown>,
281
+ where: WhereCondition
282
+ ): { sql: string; params: unknown[] } {
283
+ const safeTable = sanitizeIdentifier(table);
284
+ const keys = Object.keys(data);
285
+ if (keys.length === 0) {
286
+ throw new Error(`Cannot update record in ${table} with empty data`);
287
+ }
288
+
289
+ const params: unknown[] = [];
290
+ const setClauses = keys.map((key) => {
291
+ params.push(data[key]);
292
+ return `${sanitizeIdentifier(key)} = $${params.length}`;
293
+ });
294
+
295
+ let sql = `UPDATE ${safeTable} SET ${setClauses.join(', ')}`;
296
+
297
+ const whereItems = normalizeWhere(where);
298
+ if (whereItems.length === 0) {
299
+ throw new Error(`Unconditional UPDATE on ${table} is blocked for safety. Provide a WHERE condition.`);
300
+ }
301
+
302
+ const conditions: string[] = [];
303
+ for (const item of whereItems) {
304
+ const col = sanitizeIdentifier(item.column);
305
+ const op = item.op.toUpperCase() as WhereOperator;
306
+ if (op === 'IS NULL' || op === 'IS NOT NULL') {
307
+ conditions.push(`${col} ${op}`);
308
+ } else {
309
+ params.push(item.value);
310
+ conditions.push(`${col} ${op} $${params.length}`);
311
+ }
312
+ }
313
+
314
+ sql += ` WHERE ${conditions.join(' AND ')} RETURNING *;`;
315
+ return { sql, params };
316
+ }
317
+
318
+ /**
319
+ * Compiles a DELETE query returning count/rows.
320
+ */
321
+ export function compileDelete(table: string, where: WhereCondition): { sql: string; params: unknown[] } {
322
+ const safeTable = sanitizeIdentifier(table);
323
+ const whereItems = normalizeWhere(where);
324
+ if (whereItems.length === 0) {
325
+ throw new Error(`Unconditional DELETE on ${table} is blocked for safety. Provide a WHERE condition.`);
326
+ }
327
+
328
+ const params: unknown[] = [];
329
+ const conditions: string[] = [];
330
+ for (const item of whereItems) {
331
+ const col = sanitizeIdentifier(item.column);
332
+ const op = item.op.toUpperCase() as WhereOperator;
333
+ if (op === 'IS NULL' || op === 'IS NOT NULL') {
334
+ conditions.push(`${col} ${op}`);
335
+ } else {
336
+ params.push(item.value);
337
+ conditions.push(`${col} ${op} $${params.length}`);
338
+ }
339
+ }
340
+
341
+ const sql = `DELETE FROM ${safeTable} WHERE ${conditions.join(' AND ')} RETURNING *;`;
342
+ return { sql, params };
343
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * SynapseJS - Transparent RPC Client
3
+ *
4
+ * Consumed by generated client-side stubs. Keeps the zero-throw contract across
5
+ * the network boundary: a malformed or unreachable RPC becomes an Err value
6
+ * instead of an exception, and never a business error the caller must guess.
7
+ */
8
+
9
+ import { Err } from './machine-types';
10
+
11
+ export type RpcTransportError = 'RPC_MALFORMED' | 'RPC_UNREACHABLE';
12
+
13
+ /**
14
+ * Transport-level failure, deliberately outside the slice's domain error union:
15
+ * a broken response is not a business outcome.
16
+ */
17
+ export function rpcTransportFailure<R>(error: RpcTransportError): R {
18
+ return Err(error) as R;
19
+ }
20
+
21
+ function browserCredentials(): Record<string, string> {
22
+ const scope = globalThis as { document?: { cookie?: string } };
23
+ const cookie = scope.document?.cookie;
24
+
25
+ if (!cookie) {
26
+ return {};
27
+ }
28
+
29
+ const readCookie = (name: string): string | null => {
30
+ const entry = cookie.split('; ').find((item) => item.startsWith(`${name}=`));
31
+ return entry ? decodeURIComponent(entry.slice(name.length + 1)) : null;
32
+ };
33
+
34
+ const headers: Record<string, string> = {};
35
+ const token = readCookie('synapse_token');
36
+ const roles = readCookie('synapse_roles');
37
+ if (token) {
38
+ headers.Authorization = `Bearer ${token}`;
39
+ }
40
+ if (roles) {
41
+ headers['x-user-roles'] = roles;
42
+ }
43
+
44
+ return headers;
45
+ }
46
+
47
+ /**
48
+ * Calls a slice RPC endpoint and returns its Result payload verbatim.
49
+ * The response is shape-checked: anything without the `ok` discriminant is a
50
+ * transport failure rather than a value the caller would silently accept.
51
+ */
52
+ export async function rpcCall<R>(endpoint: string, payload: unknown): Promise<R> {
53
+ try {
54
+ const response = await fetch(endpoint, {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json', ...browserCredentials() },
57
+ body: JSON.stringify(payload)
58
+ });
59
+
60
+ const data = (await response.json().catch(() => null)) as unknown;
61
+
62
+ if (!data || typeof data !== 'object' || !('ok' in data)) {
63
+ return rpcTransportFailure<R>('RPC_MALFORMED');
64
+ }
65
+
66
+ return data as R;
67
+ } catch {
68
+ return rpcTransportFailure<R>('RPC_UNREACHABLE');
69
+ }
70
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * SynapseJS - Session & Role-Based Access Control (RBAC) Context
3
+ *
4
+ * Provides explicit, deterministic security contracts for slices.
5
+ * Eliminates implicit global auth states by injecting SessionContext directly
6
+ * into server actions without throws or hidden reflection.
7
+ */
8
+
9
+ import { Err, Ok, type Result } from './machine-types';
10
+
11
+ export interface SessionContext {
12
+ readonly userId?: string;
13
+ readonly tenantId?: string;
14
+ readonly email?: string;
15
+ readonly roles: string[];
16
+ readonly isAuthenticated: boolean;
17
+ readonly token?: string;
18
+ readonly metadata?: Record<string, unknown>;
19
+ }
20
+
21
+ export function AnonymousSession(tenantId?: string): SessionContext {
22
+ return {
23
+ roles: [],
24
+ isAuthenticated: false,
25
+ tenantId
26
+ };
27
+ }
28
+
29
+ export function createSession(data: {
30
+ userId: string;
31
+ tenantId?: string;
32
+ email?: string;
33
+ roles?: string[];
34
+ token?: string;
35
+ metadata?: Record<string, unknown>;
36
+ }): SessionContext {
37
+ return {
38
+ userId: data.userId,
39
+ tenantId: data.tenantId,
40
+ email: data.email,
41
+ roles: data.roles || ['user'],
42
+ isAuthenticated: true,
43
+ token: data.token,
44
+ metadata: data.metadata || {}
45
+ };
46
+ }
47
+
48
+ export function hasRole(session: SessionContext, role: string): boolean {
49
+ return session.isAuthenticated && session.roles.includes(role);
50
+ }
51
+
52
+ export function hasAnyRole(session: SessionContext, roles: string[]): boolean {
53
+ return session.isAuthenticated && roles.some((r) => session.roles.includes(r));
54
+ }
55
+
56
+ export type AuthCheckResult = Result<SessionContext, 'UNAUTHORIZED' | 'FORBIDDEN'>;
57
+
58
+ export function requireAuth(session?: SessionContext, requiredRoles?: string[]): AuthCheckResult {
59
+ if (!session?.isAuthenticated) {
60
+ return Err('UNAUTHORIZED');
61
+ }
62
+
63
+ if (requiredRoles && requiredRoles.length > 0) {
64
+ const authorized = hasAnyRole(session, requiredRoles);
65
+ if (!authorized) {
66
+ return Err('FORBIDDEN');
67
+ }
68
+ }
69
+
70
+ return Ok(session);
71
+ }
72
+
73
+ /**
74
+ * Asserts that the session is authenticated and associated with the expected tenant ID,
75
+ * returning Err('FORBIDDEN') to protect against IDOR (Insecure Direct Object Reference) violations.
76
+ */
77
+ export function requireTenant(session?: SessionContext, expectedTenantId?: string): AuthCheckResult {
78
+ if (!session?.isAuthenticated) {
79
+ return Err('UNAUTHORIZED');
80
+ }
81
+
82
+ if (expectedTenantId && session.tenantId && session.tenantId !== expectedTenantId) {
83
+ return Err('FORBIDDEN');
84
+ }
85
+
86
+ return Ok(session);
87
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * SynapseJS - Session Cookie (browser side)
3
+ *
4
+ * The server derives the session from the signature; the browser is what carries
5
+ * the token. These are the two calls a login screen needs — store after a
6
+ * successful login, clear to log out.
7
+ *
8
+ * Nothing here touches `process.env`, `Bun.` or the database, so it stays valid
9
+ * inside a client bundle. `document` is reached through `globalThis` on purpose:
10
+ * the framework typechecks without the DOM library, and the splitter would have
11
+ * no reason to keep a DOM-typed module out of the browser anyway.
12
+ */
13
+
14
+ export const SESSION_COOKIE = 'synapse_token';
15
+ export const ROLES_COOKIE = 'synapse_roles';
16
+
17
+ /** Sessions last half a day unless the slice that signs them says otherwise. */
18
+ export const DEFAULT_SESSION_MAX_AGE_SECONDS = 43_200;
19
+
20
+ type CookieScope = { document?: { cookie?: string } };
21
+
22
+ function scope(): CookieScope {
23
+ return globalThis as CookieScope;
24
+ }
25
+
26
+ /**
27
+ * One cookie, in the shape the runtime reads: path-wide, lax (so a cross-site
28
+ * form post does not carry it) and with an explicit lifetime.
29
+ */
30
+ export function sessionCookie(name: string, value: string, maxAgeSeconds: number): string {
31
+ return `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAgeSeconds}; SameSite=Lax`;
32
+ }
33
+
34
+ /**
35
+ * Stores the token the login action returned, plus the roles the UI may read
36
+ * without asking the server. The roles cookie is a convenience for rendering —
37
+ * the server ignores it whenever a session secret is configured and always
38
+ * takes the roles from the signed claims.
39
+ */
40
+ export function storeSession(
41
+ token: string,
42
+ roles: string[] = [],
43
+ maxAgeSeconds = DEFAULT_SESSION_MAX_AGE_SECONDS
44
+ ): void {
45
+ const target = scope().document;
46
+ if (!target) {
47
+ return;
48
+ }
49
+
50
+ target.cookie = sessionCookie(SESSION_COOKIE, token, maxAgeSeconds);
51
+ target.cookie = sessionCookie(ROLES_COOKIE, roles.join(','), maxAgeSeconds);
52
+ }
53
+
54
+ /** Logs out: both cookies expire immediately. The server keeps no session state to clear. */
55
+ export function clearSession(): void {
56
+ const target = scope().document;
57
+ if (!target) {
58
+ return;
59
+ }
60
+
61
+ target.cookie = sessionCookie(SESSION_COOKIE, '', 0);
62
+ target.cookie = sessionCookie(ROLES_COOKIE, '', 0);
63
+ }
64
+
65
+ /** The roles the current cookie advertises. Rendering aid, never an authorization source. */
66
+ export function currentRoles(): string[] {
67
+ const cookie = scope().document?.cookie;
68
+ if (!cookie) {
69
+ return [];
70
+ }
71
+
72
+ const entry = cookie.split('; ').find((item) => item.startsWith(`${ROLES_COOKIE}=`));
73
+ if (!entry) {
74
+ return [];
75
+ }
76
+
77
+ return decodeURIComponent(entry.slice(ROLES_COOKIE.length + 1))
78
+ .split(',')
79
+ .map((role) => role.trim())
80
+ .filter((role) => role.length > 0);
81
+ }