Skip to content

Commit 390ca95

Browse files
authored
Merge branch 'main' into claude/issue-13799-self-test-roster-floor-b1
2 parents 3da3d92 + 2263ca4 commit 390ca95

11 files changed

Lines changed: 720 additions & 291 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): the mail transport, brand and locale bindings no longer depend on `registerRoutes`
6+
7+
`AuthPlugin` bound five things to the live kernel on `kernel:ready` — the
8+
outbound mail transport (`setEmailService`), the SMS transport
9+
(`setSmsService`), the deployment email locale (`setDefaultEmailLocale`), the
10+
brand name (`setAppName`) and the SMS locale (`setDefaultSmsLocale`) — from
11+
inside the same hook that mounts `/api/v1/auth/*`, and that hook was gated on
12+
`registerRoutes`.
13+
14+
`registerRoutes` answers a transport-mounting question: should this plugin put
15+
its own routes on the kernel's `http-server`. The bindings are service
16+
composition, and they are true of an embedding regardless of who serves the
17+
routes. So an embedding that serves auth routes itself — the whole point of
18+
`registerRoutes: false` — came up with no mail transport, no locale on either
19+
channel and no brand binding. Silently: the `logger.info` lines that report the
20+
wiring were inside the same skipped block, and the `localization` settings
21+
namespace was not even read. One visible consequence was that the workspace
22+
language could not reach auth mail on such a host at all, and
23+
`/api/v1/auth/config` answered `requireEmailVerification: false` because
24+
`resolveRequireEmailVerification()` saw no transport.
25+
26+
The composition block now registers as its own unconditional
27+
`ctx.hook('kernel:ready', …)` — the shape the sibling diagnosis and dev-seed
28+
hooks in this plugin already use — placed before the route hook so a routing
29+
host keeps the ordering the single combined hook gave it.
30+
31+
Route registration itself stays gated: a `registerRoutes: false` kernel still
32+
mounts no auth routes.
33+
34+
**Behaviour change for `registerRoutes: false` embeddings.** They now resolve
35+
the `email`, `sms`, `i18n` and `settings` services at `kernel:ready`, apply
36+
`branding.workspace_name` and `localization.locale` (subscribing to both), seed
37+
the built-in auth SMS templates when phone sign-in is enabled, and emit the
38+
four wiring `info` lines. Hosts that had compensated by wiring these by hand
39+
should expect the plugin's own binding to run as well; both paths are
40+
idempotent setters, and an explicit workspace setting keeps outranking a
41+
manifest default exactly as it does on a routing host. Nothing changes for a
42+
host that leaves `registerRoutes` at its default.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(automation): a `try_catch` whose `catch` region itself fails now keeps the step record of both regions
6+
7+
`try_catch` returns a failure from three sites. #13803 taught the engine to fold
8+
a dying container's carried steps off the THROW channel, #14184 taught its
9+
returned-failure branch (`if (!result.success)`) to do the same, and #14184 also
10+
taught the first producer — a `try_catch` with no `catch` region — to supply
11+
them. The second producer was left unfolded: when a `catch` region is present
12+
and the handler itself fails, the return dropped `childSteps` entirely.
13+
14+
That is the same defect one path over, and the worst of the three for an
15+
operator, because TWO regions ran. The try region may have written rows before
16+
it failed; the handler may have written more before IT failed; the run log kept
17+
a step for neither, so the run summary folded over that log reported `acted: 0`
18+
over writes that had genuinely landed. `acted: 0` on a failed run reads as
19+
"nothing happened, safe to re-run", which for a non-idempotent region invites
20+
double-execution.
21+
22+
Closing it needed the half that was genuinely missing rather than the available
23+
one: the failed try attempts were already in scope, but the catch region ran
24+
without a `partialSteps` sink, so when the handler threw, the handler's own
25+
completed steps unwound with the stack. The catch region now receives the same
26+
sink the try region already had (`runRegion`'s fifth argument), and the failing
27+
return carries `[...failedTryAttempts, ...catchAttempts]` — failed try attempts
28+
first, because they happened first, which is the ordering the successful-catch
29+
return has always used. `runRegion`'s existing tagger supplies `regionKind:
30+
'try'` / `'catch'` and `parentNodeId` on its failure path as well as its
31+
success path, so the two halves stay distinguishable in the log.
32+
33+
Additive to the RECORD only. This return already reported failure with the same
34+
error text, already produced a `NODE_FAILURE` step, already set `$error` and was
35+
already routable by a `fault` edge; none of that moves, and neither does the
36+
successful-catch path or the retry/throw semantics. No engine change was needed
37+
— the fold that reads these steps has been in place since #14184.

content/docs/permissions/system-context.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ that silently does not happen.
9797
| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` |
9898
| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` |
9999
| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` |
100-
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1353` |
100+
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1380` |
101101
| 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` |
102102
| 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` |
103103
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
@@ -198,7 +198,7 @@ assuming `isSystem` covers it is a documented source of bugs.
198198
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10007``10024` |
199199
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) |
200200
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280``281` |
201-
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
201+
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` |
202202
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1522`, `:1551`; `domains/actions.ts:404` |
203203

204204
---

packages/cli/src/commands/serve.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1917,10 +1917,11 @@ export default class Serve extends Command {
19171917
// compiled artifact is reachable (explicit OS_ARTIFACT_PATH —
19181918
// including http(s):// URLs — or the canonical
19191919
// `<cwd>/dist/objectstack.json`), boot from that artifact alone.
1920-
// This is the same capability previously hard-coded in
1921-
// `apps/objectos/objectstack.config.ts`, lifted into the framework
1922-
// so any project can `objectstack start` against just a
1923-
// `dist/objectstack.json`.
1920+
// This is the same capability previously hard-coded in the tenant
1921+
// runtime's own `objectstack.config.ts` — that app is `apps/objectos` in
1922+
// the separate `objectstack-ai/cloud` repo, NOT a path in this one — and
1923+
// lifted into the framework so any project can `objectstack start`
1924+
// against just a `dist/objectstack.json`.
19241925
const configMissing = !configExists;
19251926
let useArtifactFallback = false;
19261927
let useEmptyBoot = false;
@@ -2786,10 +2787,17 @@ export default class Serve extends Command {
27862787
// need this wrap when they ALSO carry top-level metadata — otherwise
27872788
// top-level `flows`, `objects`, etc. never reach the ObjectQL registry
27882789
// and downstream services like AutomationServicePlugin start with 0 flows.
2790+
// `examples/app-showcase` is the in-repo worked example of exactly that
2791+
// shape: its `plugins[]` holds instantiated connector plugins while the
2792+
// stack still declares top-level `objects` / `apps` / `flows` / `apis`
2793+
// (`serve-host-config-security-registrar.pin.test.ts` records an `os dev`
2794+
// boot of it).
27892795
//
27902796
// To avoid double-registration when the host already wraps itself with
2791-
// an AppPlugin (e.g. apps/objectos's dev-workspace stack), we skip if
2792-
// any plugin in `plugins[]` is already an AppPlugin instance.
2797+
// an AppPlugin, we skip if any plugin in `plugins[]` is already an
2798+
// AppPlugin instance. That branch keys on the SHAPE — a `plugins[]` that
2799+
// already holds an AppPlugin instance — and never on a named app, so it
2800+
// is checked structurally below.
27932801
const hasAppPluginAlready = plugins.some(isAppPluginLike);
27942802
const configHasMetadata = !!(
27952803
config.objects || config.manifest || config.apps || config.flows || config.apis
@@ -2962,9 +2970,9 @@ export default class Serve extends Command {
29622970
|| p.constructor?.name === 'I18nServicePlugin'
29632971
);
29642972
// Check the top-level config AND any nested AppPlugin bundles in the
2965-
// `plugins` array — host/aggregator configs (e.g. apps/objectos) don't
2966-
// define translations themselves but compose multiple `new AppPlugin(...)`
2967-
// entries, each carrying its own translations.
2973+
// `plugins` array — a host/aggregator config may define no translations
2974+
// of its own and instead compose several `new AppPlugin(...)` entries,
2975+
// each carrying its own. Keyed on that shape, not on a named app.
29682976
const pluginBundleHasTranslations = (bundle: any): boolean => {
29692977
if (!bundle || typeof bundle !== 'object') return false;
29702978
if (Array.isArray(bundle.translations) && bundle.translations.length > 0) return true;

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5135,7 +5135,7 @@ export class AuthManager {
51355135
* generated docs and the #13816 refusal all asserted the ban.
51365136
*
51375137
* This method restores declared = enforced by routing the state to the
5138-
* platform's OWN ban write (`admin-ban-endpoints.ts`):
5138+
* platform's OWN ban write (`user-ban-write.ts`):
51395139
*
51405140
* - `active: false` on a row that is not banned ⇒ `applyUserBan` with
51415141
* `SCIM_DEACTIVATION_BAN_REASON` and no expiry. The vendor's
@@ -5164,8 +5164,14 @@ export class AuthManager {
51645164
* A consequence worth stating: on 1.7.2 a SCIM `DELETE /Users/{id}` no
51655165
* longer deletes the better-auth user (the vendor tombstones the source);
51665166
* it leaves the user with no active source, so this callback disables the
5167-
* account. Re-provisioning through the tombstone re-links the same user,
5168-
* the state turns active, and the SCIM ban is lifted by the second bullet.
5167+
* account — by the SAME branch as `active: false`, including over an
5168+
* administrator's timed ban, whose `banExpires` a DELETE therefore clears
5169+
* too: a deprovision cannot be outlived by an expiry the administrator set.
5170+
* For the same reason a DELETE is judged by the `beforeUpdate` guard below
5171+
* and never by any `beforeDelete` — deleting the last administrator through
5172+
* SCIM is refused exactly as deactivating them is. Re-provisioning through
5173+
* the tombstone re-links the same user, the state turns active, and the
5174+
* SCIM ban is lifted by the second bullet.
51695175
*
51705176
* The break-glass last-administrator guard (ADR-0024 D5.2, #5892) is an
51715177
* ENGINE `beforeUpdate` hook on `sys_user`, so it judges this write exactly

0 commit comments

Comments
 (0)