@ismaelsoilet/synapsejs 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,7 +21,7 @@ Features are listed with the command that fails when they break. Nothing is docu
21
21
  without one — see the repository's `AGENTS.md` for the full contract.
22
22
 
23
23
  **Stable:** vertical slices (N = 1) · `Result<T, E>` flows · TypeBox JIT contracts · declarative
24
- `sliceSchema` migrations with DAG topological ordering · bidirectional `-- up:` / `-- down:` rollbacks (`synapse rollback`) · embedded SQLite (WAL, tuned busy_timeout & cache pragmas delivering 4,000+ req/s, CTE-safe) ·
24
+ `sliceSchema` migrations with DAG topological ordering · bidirectional `-- up:` / `-- down:` rollbacks (`synapse rollback`) · embedded SQLite (WAL, tuned busy_timeout & cache pragmas, CTE-safe; measured by `bun run bench:concurrency`) ·
25
25
  full-duplex WebSockets (`defineSocket` + `useWebSocket`) · hierarchical sub-slice composition & outlets (`<SubSlice>`, `<SliceOutlet>`) · in-memory SSR micro-cache & ISR (`defineCache`) ·
26
26
  vendor bundle code-splitting via Import Maps (`compiler.vendorChunks`) · standardized shared domain modules (`synapse new-shared`) ·
27
27
  isomorphic Query Builder with parameterized relational joins and sub-object nesting (`nestJoinedRow`) · centralized Schema Catalog (`.codebase/db-schema.d.ts`) ·
@@ -47,8 +47,10 @@ implemented with and without the slice convention, including what the numbers do
47
47
  `fc` and `MockDatabaseClient` are deliberate parts of the contract, not accidents: slices declare their
48
48
  invariants with `fc`, and the oracles use the mock. Both are asserted by
49
49
  `packages/synapse/test/machine-types.test.ts` ("public contract"), together with every other export this
50
- README lists. The same block is the list of what 1.0 will freeze. In 0.x a minor bump may still break;
51
- every break is in [CHANGELOG.md](../CHANGELOG.md).
50
+ README lists. The same block is the list of frozen surfaces. A minor bump may still break in 1.x;
51
+ every break is listed in [CHANGELOG.md](../../CHANGELOG.md), each with the flag that restores the
52
+ previous behaviour where one exists — start with
53
+ [`docs/migration-1.9.md`](../../docs/migration-1.9.md) if you are coming from 1.8.
52
54
 
53
55
  ## Quick start
54
56
 
@@ -131,9 +133,98 @@ bun run mcp # JSON-RPC 2.0 over stdio
131
133
  Tools: `synapse_get_repo_map`, `synapse_get_db_schema`, `synapse_check`, `synapse_split`,
132
134
  `synapse_run_pbt`, `synapse_scaffold_slice`, `synapse_migrate`, `synapse_rollback`, `synapse_contract`,
133
135
  `synapse_check_db_drift`, `synapse_diff_impact`, `synapse_test_gate`, `synapse_abort_check`,
134
- `synapse_verify_step`, `synapse_reasoning_effort`, `synapse_triage_error`, `synapse_route_task`, `synapse_evaluate_nudge`.
136
+ `synapse_verify_completion`, `synapse_reasoning_effort`, `synapse_triage_error`, `synapse_route_task`, `synapse_evaluate_nudge`.
135
137
  Slice-dependent tools fail with `NO_SLICES_DIR` (listing the paths they examined) instead of reporting an empty success.
136
138
 
139
+ ## Write-surface containment
140
+
141
+ Every surface that turns caller input into a filesystem path segment goes through the single
142
+ validator in `core/path-guard.ts`: `synapse new-slice` (`domain` and `name`), `synapse new-shared`
143
+ (`name`), the CRUD scaffold, `synapse new`'s project directory and the upload parser. It rejects
144
+ separators, parent traversal, absolute prefixes, empty segments and over-length input, and the
145
+ resolved target must stay inside the directory the operation owns before anything is written.
146
+ `--fields` names are validated as plain identifiers before they reach the generated DDL, so a crafted
147
+ name cannot end up in a migration.
148
+
149
+ The failures are machine-readable and non-zero:
150
+
151
+ | code | meaning |
152
+ |---|---|
153
+ | `INVALID_PATH_SEGMENT` | a domain, slice name, shared-module name or project directory is not a single safe path segment |
154
+ | `INVALID_FIELD_NAME` | a `--fields` name or enum value is not a plain identifier |
155
+ | `SLICE_EXISTS` | the target file already exists |
156
+ | `WRITE_FAILED` | the filesystem refused the write |
157
+ | `NO_SLICES_DIR` | no `src/slices` was found; `candidates[]` lists every path examined |
158
+ | `AMBIGUOUS_SLICES_DIR` | several workspace members own slices; set `SYNAPSE_ROOT` or run inside the target app |
159
+
160
+ Uploads apply the same name rule and additionally refuse a file whose declared extension is outside
161
+ `SYNAPSE_UPLOAD_ALLOWED_TYPES` (default: png, jpg, jpeg, gif, webp, pdf, txt, csv, json, md) or whose
162
+ bytes do not match that extension, answering `415 UNSUPPORTED_FILE_TYPE`. The framework does not serve
163
+ the upload directory — serving it is the application's responsibility.
164
+
165
+ ## Sessions and cookies
166
+
167
+ With `SYNAPSE_SESSION_SECRET` set, a bearer token must be a signed session and its claims win: role
168
+ headers are ignored and the tenant comes exclusively from the verified claims. Revocation is consulted
169
+ on every request — in-process first, then against the persisted blacklist. Without a secret, identity
170
+ headers are honoured **only** under the explicit development opt-in `SYNAPSE_DEV_HEADERS=true` (or a
171
+ `development`/`test` runtime mode); an unset, staging or production environment resolves every request
172
+ to an anonymous session, and `synapse start` refuses to run in production without a secret unless the
173
+ operator passes `--acknowledge-insecure`.
174
+
175
+ `ctx.setCookie` is secure by default: a cookie written with no options carries `HttpOnly`, `Secure`
176
+ and `SameSite=Lax`, and only an explicit `false` opts out. A cookie written by **browser** script
177
+ (`storeSession`) cannot: `document.cookie` cannot set `HttpOnly`, by construction, so a
178
+ script-written session cookie is readable by any script in the origin. A session that must be
179
+ unreachable by script is the one the server emits through `ctx.setCookie`.
180
+
181
+ ## Realtime: declared topics and a fail-closed upgrade
182
+
183
+ A topic is declared, never inferred — a name is not a permission. Declare it in the slice that owns it:
184
+
185
+ ```typescript
186
+ import { defineTopic } from 'synapsejs';
187
+
188
+ export const sliceRealtime = defineTopic({ name: 'tickets', owner: 'tickets/view-tickets', readRoles: ['support'] });
189
+ ```
190
+
191
+ `GET /_synapse/sse/tickets` then refuses an unauthenticated subscriber (`401 UNAUTHENTICATED`), an
192
+ undeclared topic (`404 TOPIC_NOT_FOUND`) and a session without a declared role
193
+ (`403 TOPIC_FORBIDDEN`). Topics resolve inside the subscriber's tenant, so two tenants using the same
194
+ name never see each other's events, and `ctx.broadcast(topic, data)` notifies zero subscribers unless
195
+ the calling slice is the declared owner. The registry and concurrent subscriptions are bounded
196
+ (`config.realtime`), and a disconnect releases the topic entry.
197
+
198
+ The WebSocket upgrade (`GET /_synapse/ws/<domain>/<name>`) is fail-closed on origin: with no
199
+ `SYNAPSE_ALLOWED_ORIGINS` configured every upgrade is refused, an absent or opaque `null` origin is
200
+ always refused, and only a loopback origin is carved out for local development. Both transports share
201
+ one rate limiter and a per-client cap on concurrent connections. Every refusal is JSON
202
+ (`{ ok: false, error, message }`) with one of `UNAUTHENTICATED`, `TOPIC_NOT_FOUND`,
203
+ `TOPIC_FORBIDDEN`, `RATE_LIMIT_EXCEEDED`, `REALTIME_LIMIT_EXCEEDED`, `ORIGIN_NOT_ALLOWED` — the same
204
+ set `synapse contract` enumerates with its statuses.
205
+
206
+ ## Per-process state, rate limiting and the image allowlist
207
+
208
+ The token bucket rate limiter, the server-render micro-cache and the realtime event hub keep their
209
+ state **in the serving process only**; there is no shared backend. A restart discards counters, cached
210
+ responses and buffered events, and N instances behind a load balancer permit up to N times the
211
+ per-instance allowance with a per-instance cache hit rate. The allowance registry is bounded
212
+ (`config.rateLimit.maxBuckets`, 10,000 by default) and a saturated registry **refuses** the excess
213
+ with `429` instead of evicting a live counter, so a rotating-identity flood cannot reset an abuser's
214
+ allowance. Forwarding headers are trusted only when `SYNAPSE_TRUST_PROXY=true` (or
215
+ `config.trustProxy`) is set; otherwise the peer address is the client identity, and every accepting
216
+ surface — RPC, uploads, webhooks, SSE, WebSocket upgrades, the image endpoint, health, metrics,
217
+ repo-map and server-rendered pages — draws from that one limiter.
218
+
219
+ `/_synapse/images/optimize` requires a session, is rate limited, caps the upstream response
220
+ (`config.imageOptimizer.maxResponseBytes`, 10 MiB by default) and the fetch duration
221
+ (`config.imageOptimizer.timeoutMs`, 10 s by default), connects to the exact address that passed
222
+ `validateExternalUrl` (so the hostname is not resolved a second time) and refuses redirects. When
223
+ `SYNAPSE_ALLOWED_IMAGE_DOMAINS` (or `config.imageOptimizer.allowedDomains`) lists hosts, only those
224
+ hosts are accepted; when nothing is configured **any publicly routable host is permitted** —
225
+ loopback, link-local, private and cloud-metadata addresses are always refused, but there is no
226
+ implicit allowlist beyond that.
227
+
137
228
  Client configuration for an agent host:
138
229
 
139
230
  ```json