Skip to content

Commit ba564fc

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16510-seed-locale-axis
# Conflicts: # content/docs/permissions/system-context.mdx
2 parents 35929cd + 8341ed2 commit ba564fc

119 files changed

Lines changed: 7857 additions & 1264 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: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/service-storage": patch
3+
"@objectstack/service-settings": patch
4+
---
5+
6+
An authorization-store OUTAGE now reaches the caller as the `503 SERVICE_UNAVAILABLE` it declares, on the storage download doors and on all four settings routes.
7+
8+
`AuthzStoreUnavailableError` exists so an outage is distinguishable from a capability denial on the wire: it declares `status: 503` and `code: SERVICE_UNAVAILABLE`, and every producer in this family already re-raises it rather than laundering it into a verdict. Two consumers then flattened it back, each in its own way, so the declared envelope never arrived.
9+
10+
**What changes on the wire.** Only on the path where the authorization store could not be READ — never when it legitimately returned no rows, and never for any other fault.
11+
12+
| door | before | after |
13+
| --- | --- | --- |
14+
| `GET /api/v1/storage/files/:fileId/url` | `403 FILE_DOWNLOAD_DENIED` / `403 ATTACHMENT_DOWNLOAD_DENIED` | `503 SERVICE_UNAVAILABLE` |
15+
| `GET /api/v1/storage/files/:fileId` | same 403, and no redirect | `503 SERVICE_UNAVAILABLE`, still no `Location` |
16+
| `GET /api/settings` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` |
17+
| `GET /api/settings/:namespace` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` |
18+
| `PUT /api/settings/:namespace` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` |
19+
| `POST /api/settings/:namespace/:actionId` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` |
20+
21+
The storage row is the one worth reading twice: an outage was answered as a **permission denial**, byte-indistinguishable from a genuine refusal, which is the precise confusion the loud-outage discipline exists to prevent. The message now names the object whose read failed and says in words that this is not a permission denial.
22+
23+
**What does NOT change.** The security posture is identical — these doors were already fail-CLOSED and still are, and the storage gate still mints no capability on an outage. Every other refusal keeps its status and code: `deny` is still `403`, `unauthenticated` still `401`, an unknown namespace still `404`, a forbidden settings context still `403`, and any fault that is not this branded outage still lands on the same untyped `500 INTERNAL_ERROR` tail it did before. The repair is scoped to the brand, not to "anything carrying a status".
24+
25+
**Why `patch` and not `minor`.** No API is added, removed or renamed; no exported signature moves; no authorable key changes. This is a released package delivering an envelope it already declared — a bug fix, which this repo bumps `patch`. The change *is* observable, which is why the FROM → TO table above is in the changeset body rather than encoded in the bump: a version number carries no mapping, and this text is what an upgrading consumer greps in `CHANGELOG.md`.
26+
27+
**If you branch on these statuses.** A client that treated the storage `403` as "this user may not have this file" was, during an outage, retrying or re-authenticating against a fault that no credential could fix; it should now treat `503` as retryable and leave the caller's permissions alone. A client that treated the settings `500` as an unrecoverable server error can now distinguish a transient store outage from a genuine internal fault.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/client": minor
5+
---
6+
7+
The automation resume route's `400 FLOW_FAILED` now says whether the run is stranded.
8+
9+
`POST /api/v1/automation/:name/runs/:runId/resume` answers a run that consumed its pause and then failed with `400 FLOW_FAILED`, and until now its `error.details` carried the run's two artefacts only (`errorMessage`, `summary`). The engine's own verdict was dropped at the door: `AutomationResult.status: 'stranded'` — a run that is terminally failed *but* repairable by an explicit operator verb, because the pause a durable decision was waiting on is gone with the failure — reached the wire as the same `400` a plain terminal failure does, so an HTTP-only caller could not tell "beyond reach" from "repair waiting".
10+
11+
- **`@objectstack/spec`** declares `ResumeFailureDetailsSchema` (`@objectstack/spec/api`): `{ runId, status?: 'failed' | 'stranded', repairable }` — the machine-readable shape of a resume failure told to the caller, declared once so every carrier of the family ruling spells the same members.
12+
- **`@objectstack/runtime`**: the resume door's `400 FLOW_FAILED` details now carry that structure beside `errorMessage` / `summary`. `runId` is the run the resume was addressed to; `status` is the engine's own stamp, forwarded verbatim when it set one and never synthesised (the subflow-child-failed exit stamps none today); `repairable` is `status === 'stranded'` and is **always present on this arm** — present-and-false on a plain terminal failure, deliberately, so an absent member reads as an older server rather than as "not repairable". The code stays `FLOW_FAILED` (no `FLOW_STRANDED` sibling is minted), so a client that treats it as terminal keeps working and one that wants to offer a repair branches on `details.repairable`, never on the message text. The trigger door and `/actions` are unchanged: they never resume, so the member is absent there and absent means "not a resume".
13+
- **`@objectstack/client`**: `automation.resume` documents the new members.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare the two operator run-lifecycle verbs on `IAutomationService``cancelRun` and `restoreConsumedSuspension` (#16495, the contract half of #13953)
6+
7+
`IAutomationService` (`contracts/automation-service.ts`) gains two OPTIONAL
8+
members, typed as the engine already implements them rather than as the
9+
ruling's `verb(runId)` shorthand, so a door calling through the contract can
10+
say who asked and why:
11+
12+
- `cancelRun?(runId: string, reason?: string): Promise<boolean>` — end a
13+
suspended run (ADR-0044's run-cancel primitive): `true` only when this call
14+
consumed a suspension, `false` when none exists under the id (idempotent
15+
success — and the answer an unreadable store lands on too, which the
16+
implementation reports at `error`).
17+
- `restoreConsumedSuspension?(runId: string, options?: { requestedBy?: string; reason?: string })`
18+
answering `{ restored: boolean; runId: string; refusal?: string; reason: string }`
19+
— the operator exit from a run a resume left terminally unresumable
20+
(`AutomationResult.status: 'stranded'`, #13909 / #13937): puts the consumed
21+
suspension back verbatim, replays no signal, undoes nothing, never resumes,
22+
never throws.
23+
24+
Both docblocks carry the #13953 ruling's persistent-face statement (maintainer
25+
2026-09-05, decision batch #42): "listing and acting go through
26+
`sys_automation_run` (the persistent face), never engine memory" — and its
27+
permission posture: platform-operator verbs gated on the existing
28+
`platform_admin` position, no new permission type, no per-run ownership.
29+
30+
Additive. Both members are optional, so every existing implementation —
31+
including the `{ execute, listFlows }` minimum the contract's own test pins —
32+
still conforms, and the one non-test implementor (`AutomationEngine` in
33+
`@objectstack/service-automation`) already satisfies both under `implements`.
34+
The result of `restoreConsumedSuspension` is a deliberately NARROWER
35+
structural shape than the engine's `SuspensionRestoreResult`: the engine's
36+
eight-member refusal vocabulary stays with the engine, so `refusal` is typed
37+
`string` on the contract (route (i); a second consumer that needs the
38+
vocabulary is a spec card). No REST route, CLI command, lister or engine
39+
behaviour moves in this change — #13953's services half owns the doors. A
40+
service that does not declare a verb has no operator door for it, and a door
41+
must probe for presence and refuse fail-closed when it is absent.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
fix(client)!: the `organizations.*` family declares the wire shapes better-auth actually sends — nineteen published `Promise< any >` returns narrowed, twenty ledger entries closed (#14314)
6+
7+
**BREAKING** for a typed caller, and it breaks nothing that ever worked at runtime. No request bytes, no URL and no response handling change: this is a declaration catching up with what the routes have always answered. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`) — the version number is not the migration signal here, this entry is.
8+
9+
<!-- adr-0087: not-required (type-surface-only packages/client/src/index.ts#organizations.setActive, packages/client/src/index.ts#organizations.get, packages/client/src/index.ts#organizations.invite, packages/client/src/index.ts#organizations.leave, packages/client/src/index.ts#organizations.updateMemberRole, packages/client/src/index.ts#organizations.getActiveMember, packages/client/src/index.ts#organizations.invitations.cancel, packages/client/src/index.ts#organizations.invitations.accept, packages/client/src/index.ts#organizations.invitations.reject, packages/client/src/index.ts#organizations.teams.create, packages/client/src/index.ts#organizations.teams.update, packages/client/src/index.ts#organizations.teams.delete, packages/client/src/index.ts#organizations.teams.addMember, packages/client/src/index.ts#organizations.teams.removeMember) A published TYPE-SURFACE narrowing. Each of the nineteen members was UNANNOTATED at the merge base, so lib.dom's `Response.json()` published it as an erased `any`; each now declares the shape its route already answered, read off the wire against a real server. The fourteen refs above are the ones the dotted-path walker can resolve. The other FIVE members narrowed by this same change — organizations.create, organizations.update, organizations.listMembers, organizations.delete and organizations.removeMember — are direct members of `organizations` whose names recur inside its nested `teams` / `invitations` literals, and the walker counts nested same-named definitions inside the region, so they have no resolvable spelling today (filed as #16571); they carry the same annotation change, are pinned by the same type-level test and are judged by check:exported-any-returns on the built dist. No method body changed, so no request or response byte moves, and the diff touches no `packages/spec` path and no ADR-0087 shape surface. The affected party is a TypeScript consumer and the compiler delivers the break at their own call site; `objectstack migrate meta`, `spec-changes.json` and the upgrade guide have nothing to rewrite, so a ledger entry would be false data in the one ledger this gate keeps true. The twentieth ledger entry, `organizations.invitations.resend`, carries no annotation of its own and closes because it delegates to the now-bound `invite`; it is not named here because its site is unchanged. -->
10+
11+
Card 3 of 3 of the #12104 family, under the maintainer's 2026-08-31 ruling: the wire contract is the only source of truth, better-auth's own `Date`-typed fields are the pre-serialization SERVER shape, and every timestamp is declared as the ISO-8601 `string` the wire carries — no `Date`, no revival layer.
12+
13+
## What changed
14+
15+
Nineteen `organizations.*` methods ended `return res.json()` with no return annotation, so `lib.dom`'s `Response.json(): Promise< any >` was their published type. Each now declares the shape its route serves, and its `exported-any-returns.json` entry is deleted in the same change — together with the entry for `organizations.invitations.resend`, which has no annotation of its own and inherits `invite`'s (22 entries before, 2 after):
16+
17+
| method | resolved to (before) | resolves to (now) |
18+
|:--|:--|:--|
19+
| `client.organizations.create(req)` | `any` | `OrganizationCreateResult` |
20+
| `client.organizations.update(id, data)` | `any` | `OrganizationEchoWire` |
21+
| `client.organizations.setActive(id)` | `any` | `OrganizationWire \| null` |
22+
| `client.organizations.get(id)` | `any` | `OrganizationFullWire \| null` |
23+
| `client.organizations.listMembers(id)` | `any` | `OrganizationMembersPage` |
24+
| `client.organizations.invite(req)` | `any` | `OrganizationInvitationWire<'pending'>` |
25+
| `client.organizations.leave(id)` | `any` | `OrganizationMemberWithUserWire` |
26+
| `client.organizations.delete(id)` | `any` | `OrganizationWire` |
27+
| `client.organizations.removeMember(id, params)` | `any` | `OrganizationRemoveMemberResult` |
28+
| `client.organizations.updateMemberRole(id, params)` | `any` | `OrganizationMemberWire` |
29+
| `client.organizations.getActiveMember(id)` | `any` | `OrganizationMemberWithUserWire` |
30+
| `client.organizations.invitations.cancel(id)` | `any` | `OrganizationInvitationWire<'canceled'>` |
31+
| `client.organizations.invitations.accept(id)` | `any` | `OrganizationInvitationAcceptResult` |
32+
| `client.organizations.invitations.reject(id)` | `any` | `OrganizationInvitationRejectResult` |
33+
| `client.organizations.invitations.resend(inv)` | `any` (inherited) | `OrganizationInvitationWire<'pending'>` (inherited from `invite`) |
34+
| `client.organizations.teams.create(req)` | `any` | `OrganizationTeamWire` |
35+
| `client.organizations.teams.update(params)` | `any` | `OrganizationTeamWire` |
36+
| `client.organizations.teams.delete(params)` | `any` | `OrganizationTeamRemovedReceipt` |
37+
| `client.organizations.teams.addMember(params)` | `any` | `OrganizationTeamMemberWire` |
38+
| `client.organizations.teams.removeMember(params)` | `any` | `OrganizationTeamMemberRemovedReceipt` |
39+
40+
`OrganizationWire`, `OrganizationEchoWire`, `OrganizationCreateResult`, `OrganizationFullWire`, `OrganizationMemberWire`, `OrganizationMemberUserWire`, `OrganizationMemberWithUserWire`, `OrganizationMembersPage`, `OrganizationRemoveMemberResult`, `OrganizationInvitationWire`, `OrganizationInvitationAcceptResult`, `OrganizationInvitationRejectResult`, `OrganizationTeamWire`, `OrganizationFullTeamWire`, `OrganizationTeamMemberWire`, `OrganizationTeamRemovedReceipt` and `OrganizationTeamMemberRemovedReceipt` are newly exported from `@objectstack/client`. Every one of these routes is served BARE by better-auth (`auth-route-ledger.ts` records them `source: 'better-auth'`) — there is no `{ success, data }` envelope to unwrap and none is introduced. `@objectstack/spec/identity`'s `Organization` / `Member` / `Invitation` are deliberately NOT relayed: each declares `updatedAt` required, and the wire never carries it (the adapter's output transform walks better-auth's own schema, which has no such column); `InvitationStatus` IS relayed, narrowed to the literal each handler pins.
41+
42+
## The exact reads that stop compiling
43+
44+
Everything below compiled before only because `any` is assignable to, and indexable by, everything.
45+
46+
```ts
47+
const org = await client.organizations.setActive(id);
48+
org.id; // now TS18047 — `setActive` (and `get`) answer `null` for an empty id with no active organization
49+
if (org?.metadata) JSON.parse(org.metadata); // fine — on the READ routes `metadata` is the stored JSON text, `null`/absent when unset
50+
(await client.organizations.get(id))!.metadata.plan; // now TS2339 — it is a string here, not an object
51+
52+
const echo = await client.organizations.update(id, { metadata: { plan: 'pro' } });
53+
JSON.parse(echo.metadata); // now TS2345 — the two WRITE routes (`create`, `update`) echo `metadata` already decoded
54+
55+
const deleted = await client.organizations.delete(id);
56+
deleted.length; // now TS2339 — the route answers the organization ROW, not the id string the vendor's OpenAPI stub declares
57+
deleted.updatedAt; // now TS2339 — `sys_organization.updated_at` never reaches the wire
58+
deleted.createdAt.getTime(); // now TS2339 — ISO-8601 STRING, not a Date; `new Date(deleted.createdAt)` is the rewrite
59+
60+
const m = await client.organizations.updateMemberRole(id, { memberId, role: 'admin' });
61+
m.member.role; // now TS2339 — the row is answered BARE, not as `{ member }` (the vendor's stub is wrong)
62+
63+
const removed = await client.organizations.removeMember(id, { memberIdOrEmail });
64+
removed.member.user.email; // now TS18048 — `user` is joined on ONLY when the member was addressed by email
65+
66+
const inv = await client.organizations.invite({ email, organizationId: id });
67+
if (inv.status === 'accepted') { /* now TS2367 — `invite` answers the literal `'pending'` */ }
68+
69+
(await client.organizations.listMembers(id)).data; // now TS2339 — no envelope on any route of this family
70+
```
71+
72+
A caller that read `id`, `name`, `slug`, `role`, `email`, `members`, `total` or `message` off these values, or narrowed `null` where it can arrive, needs no change.
73+
74+
## Timestamps: ISO-8601 `string`, never `Date`
75+
76+
`createdAt` on every row type, `updatedAt` on teams and `expiresAt` on invitations are the vendor's `Date`-typed fields. The adapter is declared `supportsDates: false`, better-auth revives the stored string into a `Date` server-side, and `JSON.stringify` puts an ISO-8601 string back on the wire — measured `"createdAt":"2026-09-07T09:27:01.545Z"` on a real SQL driver. They are declared `string`, a type-level pin holds them there, and no revival layer exists in the SDK.
77+
78+
## Where the vendor's own declarations were the wrong answer
79+
80+
- `delete`'s OpenAPI stub declares the deleted id as a `string`; the handler answers the organization row.
81+
- `updateMemberRole`'s stub declares `{ member }`; the handler answers the membership row bare, without `user`.
82+
- `metadata` is one column with two wire forms: `create` and `update` decode it, every read route answers the stored JSON text (`setActive`, `get`, `delete`, `list`).
83+
- `removeMember` joins `user` on only when the member was addressed by email; the by-id path strips it.
84+
- Inside `get(...).teams` the vendor's `memberCount` is NOT stripped (it is on `teams.create` / `teams.update`). `teams.update` writes no timestamp of its own — `updatedAt` there comes from better-auth's team schema (`onUpdate` default, applied on every update) with the platform's `sys_team.updated_at` stamping behind it, measured on a real SQL driver; the default team minted at organization creation is written without `updatedAt` by the vendor and carries the platform's stamp, so `get(...).teams[].updatedAt` is declared optional as the safe direction.
85+
86+
## Not a behaviour change
87+
88+
`getActiveMember(organizationId)` keeps sending its query parameter; the measured fact that the server ignores it and answers the session's ACTIVE organization is recorded in the method's JSDoc and filed separately — a body change is outside this family's ruled narrowing scope.

0 commit comments

Comments
 (0)