Skip to content

Commit dbf91ca

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15684-analytics-like-case-exact
2 parents 27bf56f + c99449a commit dbf91ca

50 files changed

Lines changed: 3612 additions & 365 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `FlowSchema` refuses a flow whose `edges[]` declares the same id twice (#14964)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is renamed, retired or re-typed: `edges[].id` keeps its name, its type and its describe, and every flow whose edge ids are unique parses byte-identically. The only newly refused shape is two edges sharing one id — a collision, not a spelling — and its remedy is to renumber one of the two, which is authoring intent no `objectstack migrate meta` rewrite can choose for the author. The Zone-2 census over this repo (776 `edges[]` arrays, 1,098 edges under `packages/**` and `examples/**`, with a lit control) found zero instances, so there is no in-repo file to name. -->
8+
9+
**BREAKING** accept-set narrowing on `FlowSchema` — a flow whose `edges[]`
10+
carries two edges with the same `id` is now **refused at parse time** — by
11+
`FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that validates a
12+
flow through the schema (`objectstack validate`, the runtime publish gate, a
13+
stack's `flows[]`) — where it used to parse on green. Shipped as `minor` under
14+
the repo's launch-window convention for breaking changes. Maintainer ruling
15+
2026-09-05 on #14964 (director decision batch #40, verbatim 「同意」): option
16+
A — an `error`, not a `warning`; no opt-out, no transition window.
17+
18+
Every reader of an edge id assumes the ids in a flow are unique — a designer,
19+
a BPMN export, a flow diff, any traversal that dedupes by id — and nothing
20+
enforced it. A real duplicate (`id: 'e20'` on two edges of one flow) shipped
21+
through two releases of green CI in a downstream app and was inert only
22+
because the engine keys out-edges by `source`, never by `id`: the collision is
23+
invisible until something keys on ids, and then silently wrong rather than
24+
loudly broken. The id space is hand-authored, so the next author picking a
25+
"free" id from the sequence had no way to know it was taken.
26+
27+
**What changes** (`packages/spec/src/automation/flow.zod.ts`): a `superRefine`
28+
on the flow's `edges[]`. Each later occurrence of an already-declared id raises
29+
one `custom` issue, anchored at `edges[N].id` of the *later* edge and naming
30+
both positions, so the formatted error points at the edge to renumber:
31+
32+
```text
33+
✗ edges.7.id: Duplicate edge id `e20` — `edges[7]` reuses the id already declared by `edges[3]`; every edge id in a flow must be unique. Renumber one of them: …
34+
```
35+
36+
**What does NOT change:** `edges[].id` keeps its name, type and describe; the
37+
node vocabulary, the edge `type` enum and every other refusal are untouched;
38+
a flow with unique edge ids (or no edges) parses exactly as before. Node ids
39+
are not covered by this change.
40+
41+
The shape that is refused, and what the author does about it — a two-edge
42+
excerpt, the later edge renumbered:
43+
44+
```ts
45+
// before — parsed on green, both edges keyed 'e20'
46+
edges: [
47+
{ id: 'e20', source: 'qualify', target: 'convert' },
48+
{ id: 'e20', source: 'convert', target: 'end' },
49+
]
50+
51+
// after — refused at parse (edges.1.id: Duplicate edge id `e20` …); renumber the later one:
52+
edges: [
53+
{ id: 'e20', source: 'qualify', target: 'convert' },
54+
{ id: 'e21', source: 'convert', target: 'end' },
55+
]
56+
```
57+
58+
**Remedy.** Renumber the later edge to an id no other edge in that flow
59+
carries; nothing else in the flow needs to move. The census over this
60+
repository found no flow to migrate, so this is a release note, not a
61+
migration: no shipped example, fixture or seed in `packages/**` or
62+
`examples/**` declares a duplicate edge id, and the pinned objectui tree
63+
carries none in its authored flows. The one known downstream instance was
64+
renumbered before this change (hotcrm PR #1571).
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
No authoring rule throws on a non-record entry of any stack collection.
6+
7+
A collection is authored either as a list or as a name-keyed map, so every rule that reads one coerces `unknown` into an array of records first. That coercion had been hand-copied into 39 modules, and 23 of the copies spelled the array branch as an unchecked cast — every member was asserted to be a record. A YAML list item left empty deserialises to `null`, so a single stray `-` under `flows:`, `pages:`, `dashboards:`, `datasets:`, `apps:`, `permissions:`, `capabilities:`, `data:`, `hooks:`, `views:`, `actions:`, `translations:` (or a per-object `fields:` / `actions:` / `views:`) reached a property read on `null` and threw a stack trace out of `os lint` / `os validate` instead of reporting a finding. The rules are pure `(stack) => Finding[]` running on the raw path, so nothing upstream had judged the entry's shape.
8+
9+
Twenty-two of those readers now read through the shared, guarded `recordsOf`, which drops a non-record member of the array shape whole and keeps the author's key on the map shape. Nothing else about what the rules judge changes: a valid entry standing beside a junk one is still read, and still draws exactly the findings it drew before.
10+
11+
The remaining copies are pinned by a new source-text test in the package, so the predicate cannot be pasted back in: it asserts that `recordsOf` is the only collection coercion, that every module still holding a private one is named in a dated ledger that is exact in both directions, and that no coercion outside a dated single-file allowance casts its array branch unchecked.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os serve` now says so when the SQLite file it is serving is no longer the file at its configured path.
6+
7+
Deleting the data directory under a running server — `rm -rf .objectstack/data`, which is what a `demo:reset` script does and what a fresh-database repro starts with — unlinks the inode without touching the process. SQLite keeps reading and writing the now-invisible file, health keeps answering `200`, and a later boot creates a brand-new database at the same path. From that moment every filesystem inspection of that path describes a *different* database than the running server answers from, and nothing anywhere says so: a row edited there has no observable effect on the live server, and a user who authenticates against the live server is not in that file. Both readings are true, both look like a broken write path, and one investigation that reported them as evidence cost a full P0 cycle.
8+
9+
A boot that serves an on-disk SQLite file now records that file's identity once the boot is complete and re-checks it on a 30-second interval. When the file is gone, or the path holds a different file, it reports **once** at `error` — naming the path, the consequence (every external observation of this deployment is now false, and it will keep looking healthy) and the fix (restart the server so it opens the file that is at that path now).
10+
11+
It refuses nothing and retries nothing: the running server is still correct, merely invisible, and breaking a working dev loop to fix a reporting gap would trade a bad hour for a worse one. Nothing is added to any payload, endpoint or state file. Silence from the check is not a claim that the file is intact — every uncertainty in it resolves toward staying quiet, because a false report would send an operator to restart a server whose database is fine.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
`POST /sign-up/email` for an address that already has a `sys_user` row is refused explicitly, instead of answering 200 for a row that is never written (#15587)
7+
8+
**This is a wire-behaviour change on one lane**: a call that answers `200 {"token":null,"user":{…}}` today answers `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` after this change. Nothing is newly admitted — the response that changes is one that reported a creation that never happened.
9+
10+
### What was measured
11+
12+
Under audience posture `email_domain` (domain allowlisted, `selfRegistrationPermissionSet` resolvable), a sign-up for an address that already carried a `sys_user` row answered **200 with a freshly minted user id** and persisted nothing: no new `sys_user`, no `sys_account`, and the next sign-in a `401` with nothing anywhere explaining it. The same call on the same population under the `invite_only` default was refused honestly with `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`. An operator, a provisioning script or the console reading the status code concludes the account exists — and this sits directly on the recovery path a locked-out deployment walks, where widening the posture to let a seeded person register is exactly the remedy an operator is pointed at.
13+
14+
### The mechanism
15+
16+
better-auth's sign-up route computes `shouldReturnGenericDuplicateResponse = requireEmailVerification || autoSignIn === false` and, when it is on, answers a duplicate with a synthetic in-memory user instead of throwing. **No insert is attempted and nothing is swallowed**: the vendor's `findUserByEmail` short-circuits ahead of `createUser`, which is why no row and no credential appear.
17+
18+
The posture is not itself the cause — it is only what arms the shield: a posture that permits self-registration **forces** `requireEmailVerification` on. Holding the posture constant at the `invite_only` default and moving only that flag reproduces the divergence exactly, which also means the defect was never confined to the widened postures: `emailAndPassword.autoSignIn: false` arms the same shield under any posture.
19+
20+
### The fix
21+
22+
The uniqueness refusal is raised on the `/sign-up/email` before-hook, the same seam and the same reason the audience-posture refusal is already raised there, and built from better-auth's own `BASE_ERROR_CODES` entry so both lanes answer byte-identically.
23+
24+
**Order is load-bearing: it runs only for a caller the posture already admitted.** Asking uniqueness first would hand an uninvited stranger an account-existence oracle under the `invite_only` default (422 for a real address versus 403 for an unknown one). After the gate, `invite_only` is untouched — a stranger still gets `SELF_REGISTRATION_CLOSED` and learns nothing.
25+
26+
**Operators of `open` / `email_domain` should know what the honest refusal costs:** on those postures a caller the audience gate admits can now distinguish an address that has an account from one that does not, where the synthetic 200 previously hid it. That is the disclosure the `invite_only` lane has always made to an invitation holder, and the platform's answer for a widened posture is now the same fact rather than a false receipt.
27+
28+
`USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` is registered in the ADR-0112 error-code ledger under `@objectstack/plugin-auth`: the platform now **emits** it rather than only passing it through, and an emitted-but-unregistered code is the silent fourth state that ledger exists to prevent.

content/docs/deployment/self-hosting.mdx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -557,11 +557,13 @@ this step.
557557
**Two remedies that look like they work, measured:**
558558

559559
- **Opening the audience posture is not enough on its own.** With the posture
560-
widened to `email_domain`, a seeded person's own registration answers `200`
561-
and persists *nothing* — no new row, no account, and their sign-in is still
562-
`401`. A fresh address does get an account, but every posture other than
563-
`invite_only` forces email verification on, so its first sign-in is refused
564-
`403 EMAIL_NOT_VERIFIED` until a mail transport delivers the link.
560+
widened to `email_domain`, a seeded person's own registration is refused
561+
`422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` and nothing is written — the same
562+
answer the `invite_only` default already gives for an address the directory
563+
holds, so the widened posture buys that person no login and their sign-in
564+
stays `401`. A fresh address does get an account, but every posture other
565+
than `invite_only` forces email verification on, so its first sign-in is
566+
refused `403 EMAIL_NOT_VERIFIED` until a mail transport delivers the link.
565567
- **A hand-written credential row authenticates nothing.** The `sys_account`
566568
row shape is public; the format of the secret stored in its `password`
567569
column is the platform's own. A row carrying a plaintext password is refused

content/docs/references/api/contract.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
30-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +293 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
30+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +294 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3131
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3232
| **message** | `string` || Readable error message |
3333
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -333,6 +333,7 @@ const result = ApiErrorSchema.parse(data);
333333
* `UPLOAD_SESSION_EXPIRED`
334334
* `UPLOAD_SESSION_NOT_FOUND`
335335
* `USER_ALREADY_EXISTS`
336+
* `USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`
336337
* `VALIDATION_FAILED`
337338
* `VERSION_NOT_FOUND`
338339
* `VERSION_NOT_RESTORABLE`

content/docs/references/api/error-code-ledger.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,7 @@ const result = ErrorCode.parse(data);
449449
* `UPLOAD_SESSION_EXPIRED`
450450
* `UPLOAD_SESSION_NOT_FOUND`
451451
* `USER_ALREADY_EXISTS`
452+
* `USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`
452453
* `VALIDATION_FAILED`
453454
* `VERSION_NOT_FOUND`
454455
* `VERSION_NOT_RESTORABLE`

packages/cli/src/commands/serve-driver-banner.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe('describeRegisteredDriver', () => {
4949

5050
expect(describeRegisteredDriver(fakeKernel({
5151
'driver.sql': { config: { client: 'better-sqlite3', connection: { filename: './data.db' } } },
52-
}))).toEqual({ label: 'SqlDriver(better-sqlite3)', url: './data.db' });
52+
}))).toEqual({ label: 'SqlDriver(better-sqlite3)', url: './data.db', sqliteFile: './data.db' });
5353

5454
expect(describeRegisteredDriver(fakeKernel({
5555
'driver.sql': { config: { client: 'pg', connection: { host: 'db.example.com', port: 5432, database: 'app' } } },
@@ -133,10 +133,42 @@ describe('describeRegisteredDriver', () => {
133133
expect(describeRegisteredDriver(kernel)).toEqual({
134134
label: 'SqliteWasmDriver',
135135
url: './app.wasm.db',
136+
sqliteFile: './app.wasm.db',
136137
});
137138
});
138139

139140
it('returns null when no known driver is registered', () => {
140141
expect(describeRegisteredDriver(fakeKernel({}))).toBeNull();
141142
});
143+
144+
// `sqliteFile` is the SAME probe read a second time asking a different
145+
// question: not "what do I print" but "which file on this filesystem", so
146+
// the boot can `stat` it and notice later that the file it opened is no
147+
// longer the file at that path. It is the plumbing between the driver and
148+
// that watch, and nothing else pins it — a probe that stopped answering it
149+
// would leave the watch silently un-armed on every boot, which is
150+
// byte-identical to a deployment where nothing ever went wrong.
151+
describe('sqliteFile — the on-disk file the boot is serving', () => {
152+
it('answers the sqlite connection filename', () => {
153+
expect(describeRegisteredDriver(fakeKernel({
154+
'driver.com.objectstack.driver.sql': {
155+
config: { client: 'better-sqlite3', connection: { filename: '/app/.objectstack/data/objectstack.db' } },
156+
},
157+
}))?.sqliteFile).toBe('/app/.objectstack/data/objectstack.db');
158+
});
159+
160+
it('is absent for every driver that serves no file on disk', () => {
161+
expect(describeRegisteredDriver(fakeKernel({
162+
'driver.sql': { config: { client: 'better-sqlite3', connection: { filename: ':memory:' } } },
163+
}))?.sqliteFile).toBeUndefined();
164+
165+
expect(describeRegisteredDriver(fakeKernel({
166+
'driver.sql': { config: { client: 'pg', connection: { host: 'db.example.com', port: 5432, database: 'app' } } },
167+
}))?.sqliteFile).toBeUndefined();
168+
169+
expect(describeRegisteredDriver(fakeKernel({
170+
'driver.memory': { constructor: { name: 'InMemoryDriver' }, config: {} },
171+
}))?.sqliteFile).toBeUndefined();
172+
});
173+
});
142174
});

0 commit comments

Comments
 (0)