Skip to content

Commit cf49cb7

Browse files
os-zhuangclaude
andcommitted
Merge origin/main: reconcile with #2556 (keep build lint + boot _diagnostics)
#2556 (view-key collision boot warnings) landed on main while this branch was open. Reconciled by keeping BOTH: the merged `expandViewContainerWithDiagnostics` now pushes `collisions[]` (consumed by the new build-time `lint-view-refs`) AND stamps per-item `_diagnostics` (consumed by the boot loaders) at the same detection points. view-expand keeps both test suites (17 pass); showcase view/ action conflicts merged; .objectui-sha takes main's pin; api-surface unchanged; full turbo build 71/71. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 parents 6ffb7ed + b165c9f commit cf49cb7

93 files changed

Lines changed: 4548 additions & 285 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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
"@objectstack/plugin-auth": minor
4+
---
5+
6+
feat(auth): last-login audit fields — sys_user.last_login_at / last_login_ip (ADR-0069 D7)
7+
8+
Completes the ADR-0069 D7 identity-field set: `sys_user.last_login_at` and
9+
`sys_user.last_login_ip` are stamped on every successful `/sign-in/email` by
10+
`AuthManager.stampLastLogin` (a best-effort after-hook, independent of the
11+
lockout-accounting path so it runs even when lockout is disabled). The IP is
12+
taken from the trusted forwarded headers (`x-forwarded-for`
13+
`cf-connecting-ip``x-real-ip`), the same precedence as the D5 IP allow-list
14+
middleware, and capped to the 45-char column width. Both fields are
15+
system-managed, read-only, and land in the Admin group of `sys_user`.
16+
17+
The rest of ADR-0069 P1 (password complexity/history/expiry, HIBP, account
18+
lockout, enforced MFA) was already implemented; this fills the one missing D7
19+
field pair. ADR-0069 status updated Proposed → Accepted (P1/P2 implemented)
20+
with an implementation-status matrix reflecting what is landed vs the remaining
21+
P2 gaps (per-org IP ranges, shared-store rate limiting).
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
feat(auth): shared cross-node rate-limit + session store via the cache service (ADR-0069 D2)
6+
7+
Multi-node deployments previously rate-limited **per process** — better-auth's
8+
default `rateLimit` store is in-memory, so each node counted independently and
9+
an attacker could rotate nodes to bypass the limit. `AuthPlugin` now wires the
10+
kernel `cache` service as better-auth's `secondaryStorage` and flips
11+
`rateLimit.storage` to `'secondary-storage'`, so rate-limit counters (and the
12+
session cache) are enforced against **one shared store across every node**
13+
shared iff the cache service is (Redis adapter in a cluster; memory single-node,
14+
where behavior is unchanged). When no cache service is registered the plugin
15+
logs a warning that a multi-node deployment needs a shared cache (ADR-0069
16+
honesty — no silent per-process limiting presented as global).
17+
18+
New `cacheSecondaryStorage(cache)` adapter (`ICacheService` → better-auth
19+
`SecondaryStorage`). Note: the cache has no atomic increment, so under high
20+
concurrency the get→set counter path can slightly over-count — acceptable for a
21+
rate limiter and strictly better than independent per-node counters; a future
22+
cache adapter exposing atomic INCR can add an `increment` method for exact
23+
counting.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/plugin-security": minor
4+
---
5+
6+
feat(security): two-doors separation for permission sets (ADR-0086 P2)
7+
8+
Splits who may change a permission set into two non-overlapping doors, enforced
9+
at the data layer instead of by convention:
10+
11+
**块1 — the package door (publish-time materialization).**
12+
`ObjectStackProtocolImplementation` gains a generic publish-time materializer
13+
registry (`registerPublishMaterializer(type, fn)`). When a draft of a registered
14+
type is published, its body is projected into a data-plane row and the result is
15+
surfaced on the publish response as `materializeApplied` (best-effort, never
16+
thrown — same contract as `seedApplied`). `promoteDraft` now returns the draft's
17+
`packageId` so the materializer can stamp the owning package. `plugin-security`
18+
registers a `permission` materializer that upserts the published set into
19+
`sys_permission_set` with `managed_by:'package'` + `package_id` — so a set
20+
authored through the studio package door (saved as a `permission` draft, then
21+
published) lands in the admin surface with the exact provenance the boot seeder
22+
already stamps, now on the runtime publish path too. The single-set upsert is
23+
shared with `bootstrapDeclaredPermissions` (`upsertPackagePermissionSet`), so
24+
both paths apply the same own-row / foreign-package / env-authored rules.
25+
26+
**块2 — the admin door (data-layer write gate).**
27+
The security middleware now refuses any admin-door write
28+
(`update`/`delete`/`transfer`/`restore`/`purge`) to a `sys_permission_set` row
29+
with `managed_by:'package'`, and refuses an `insert` that forges
30+
`managed_by:'package'`. The gate fails closed regardless of the caller's grants
31+
(a platform admin with `modifyAllRecords` is blocked just the same), so it is a
32+
real data-layer boundary rather than a UI hint. System/boot writes carry
33+
`isSystem` and bypass the whole middleware, so the boot seeder and the publish
34+
materializer are unaffected. Env-authored sets (`managed_by` `user`/`platform`
35+
or absent) stay freely editable through the admin door — the two doors never
36+
overwrite each other.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(automation): bind flow triggers on a cold boot, not just after an HMR reload
6+
7+
Record-triggered (and other trigger-typed) flows silently never fired on a
8+
fresh process start — in dev and in production. The automation service's
9+
boot-time flow pull reads `ql.registry.listItems('flow')`, which is **empty for
10+
flows defined inline in an app manifest**`registry.registerApp()` stores the
11+
app under type `'app'` and never promotes its inline flows to standalone
12+
registry `'flow'` items. The re-sync that *could* see them only ran on the
13+
`metadata:reloaded` hook, which never fires on a cold boot (`os dev` restarts
14+
the process on recompile rather than firing it, and production never reloads).
15+
16+
Net effect: after any real restart, **no flow bound its trigger**, so
17+
record-change automations did not fire at all.
18+
19+
Fix: bind flows at `kernel:ready` from `protocol.getMetaItems({ type: 'flow' })`
20+
— the canonical flattened flow view that `GET /meta/flow` serves and that does
21+
surface inline app flows — once every plugin has finished `init()`/`start()`
22+
(so the app, hence its flows, is registered). `registerFlow` is idempotent, so
23+
re-binding a flow the boot pull already registered is harmless.
24+
25+
Verified end-to-end on a clean instance: before the fix, updating a record
26+
fired **0** flows (0 bound at boot); after, a cold boot binds all flows and a
27+
single record update fires every matching record-triggered flow. Regression
28+
test boots a kernel with an inline-app record-triggered flow served only via
29+
`protocol.getMetaItems` and asserts it is bound after `bootstrap()` alone with
30+
no `metadata:reloaded` fired — it fails on the pre-fix code.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
fix(automation): bind a flow published while the server runs, without a restart
7+
8+
Follow-up to #2560 (cold-boot flow binding). A flow **published while the server
9+
is running** — the Studio online-authoring journey: author a record-triggered
10+
automation, publish it, immediately update a matching record — did **not** fire.
11+
Its trigger only bound on the next process restart.
12+
13+
Two gaps, both fixed:
14+
15+
1. **The publish path fired no rebind signal.** `POST /packages/:id/publish-drafts`
16+
`protocol.publishPackageDrafts` promotes the drafts to active but emitted no
17+
event the automation service listens to. The runtime dispatcher now announces
18+
`metadata:reloaded` after a successful publish — the same signal a dev artifact
19+
reload fires (`MetadataPlugin._reloadAndAnnounce`) — so boot-cached consumers
20+
re-sync without a restart.
21+
22+
2. **The runtime re-sync read the wrong source.** The automation service's
23+
`metadata:reloaded` re-sync pulled `metadata.list('flow')`, which returns 0 in a
24+
real running server (it does not surface inline app flows), so even when the
25+
hook fired it bound nothing. It now reads `protocol.getMetaItems({ type: 'flow' })`
26+
— the same flattened flow view #2560's cold-boot bind and `GET /meta/flow` use —
27+
while keeping the teardown of flows removed from the artifact. A failed or
28+
unavailable protocol read is a no-op and never tears down live flows.
29+
30+
Production is largely unaffected (a deploy reboots the process, so #2560's
31+
cold-boot bind covers it); this closes the gap for dev and single-instance
32+
Studio authoring.
33+
34+
Verified end-to-end on a clean instance: authored a record-triggered flow in a
35+
package, published it via `POST /packages/:id/publish-drafts` **without
36+
restarting**, then updated a matching record and observed the flow fire (before
37+
the fix it did not). New regression tests boot a kernel whose protocol serves a
38+
flow only after boot and assert `metadata:reloaded` binds it — and that the
39+
re-sync reads the protocol, not `metadata.list` — both failing on the pre-fix code.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
---
4+
5+
feat(automation): durable run history — every terminal run leaves a queryable record with its failure reason
6+
7+
Automation runs were observable **only in memory**: the engine kept the last N
8+
`ExecutionLogEntry` records in a ring buffer, so "did this flow run, and why did
9+
it fail?" could not be answered after a process restart (or once the buffer
10+
evicted the entry), and a failed run surfaced no reason at all. This was the
11+
biggest silent-trust gap for anyone authoring automations — a flow could stop
12+
firing or start failing with nothing durable to inspect.
13+
14+
`sys_automation_run` — previously the ADR-0019 store for *live suspended* runs
15+
only — becomes a durable **run-history** table. On every terminal run the engine
16+
mirrors a row through the `SuspendedRunStore` (`recordTerminal`): `status`
17+
(`completed` / `failed`), `finished_at`, `duration_ms`, and, for a failure, the
18+
`error` message a designer needs to fix it. `listRuns()` merges this durable
19+
history with the in-memory buffer (in-memory wins on id, newest-first) so the
20+
Studio "Runs" surface shows runs that predate the current process.
21+
22+
The design is **safe and additive**. Terminal history rows use a `run_`-prefixed
23+
id, disjoint from live suspended runs (which key on the raw `runId` with
24+
`status: 'paused'`), so the suspend save/load/delete/list path is untouched and
25+
resume sweeps (`list()` filters `status: 'paused'`) never see history rows.
26+
Persisting is **best-effort and fire-and-forget** — a history-write failure is
27+
logged and swallowed, never breaking the run that produced it. New object fields
28+
(`finished_at`, `duration_ms`, `error`) are all optional and the `status` enum
29+
gains `running` / `completed` / `failed` alongside the existing `paused`.
30+
31+
Verified end-to-end on a clean showcase instance: a schedule-triggered flow and
32+
seven task-completion flows each left durable `completed` rows; a genuinely
33+
failing flow (`showcase_resilient_sync`) left a `failed` row carrying its
34+
`try_catch` failure reason; a live `paused` suspended run coexisted without
35+
collision; and after a full process restart the `failed` row — reason intact —
36+
was still queryable via `/api/v1/data/sys_automation_run`. New `run-history.test.ts`
37+
covers completed/failed persistence, read-across-restart, and best-effort isolation.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
feat(security): pre-map `transfer`/`restore`/`purge` to their RBAC bits (#1883)
7+
8+
The permission evaluator now maps the destructive record-lifecycle operations
9+
to their spec permission bits (`transfer``allowTransfer`, `restore`
10+
`allowRestore`, `purge``allowPurge`) and extends the `modifyAllRecords`
11+
super-user bypass to cover them. The ObjectQL operations themselves are still
12+
roadmap M2 — but the gate now exists ahead of them: the moment such an
13+
operation is dispatched through the security middleware it is denied unless a
14+
resolved permission set grants the matching bit. Unmapped destructive
15+
operations continue to fail closed (ADR-0049). Spec descriptions updated from
16+
`[EXPERIMENTAL — not enforced]` to `[RBAC-gated; operation pending M2]`.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-security": minor
4+
---
5+
6+
feat(security): permission-set package provenance + declared-permission seeding (ADR-0086 P1)
7+
8+
Packages now ship working default access for their own objects, with a
9+
machine-checkable metadata↔config boundary:
10+
11+
- **Spec (ADR-0086 D3)**: `PermissionSetSchema.packageId` (owning package for
12+
a package-shipped set; absent = env-authored) and per-record provenance
13+
`managedBy: 'package' | 'platform' | 'user'` on the existing
14+
metadata-persistence axis. Persisted on `sys_permission_set` as
15+
`package_id` / `managed_by` (new columns + `package_id` index).
16+
- **Seeding (ADR-0086 D5)**: new `bootstrapDeclaredPermissions` — the sibling
17+
of `bootstrapDeclaredRoles` — materializes `stack.permissions` into
18+
`sys_permission_set` at boot with `managed_by:'package'` + `package_id`.
19+
Idempotent and upgrade-aware: rows the seeder owns are re-seeded to the
20+
shipped declaration on every boot; rows owned by a different package are
21+
refused loudly; env-authored `platform`/`user`/legacy rows are never
22+
clobbered. Closes the ADR-0078 inert-metadata violation for
23+
`stack.permissions` (declared sets were runtime-enforced but never
24+
materialized — invisible to the admin surface, uninstall undefined).
25+
- Conformance matrix row `declarative-permission-seeding` (ADR-0056 D10) +
26+
dogfood proof pin the behavior so it cannot regress to inert.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(security): surface swallowed permission-set resolution failures (#2565)
6+
7+
`PermissionEvaluator.resolvePermissionSets` swallowed metadata `list()` and
8+
`sys_permission_set` dbLoader failures silently — fail-closed (unresolvable
9+
sets grant nothing), but a transient DB error made custom permission sets
10+
vanish with no trace, leaving the resulting 403s undiagnosable. The evaluator
11+
now accepts an optional `{ logger }` and emits one `warn` per failed source,
12+
naming the unresolved permission sets and the error. SecurityPlugin wires its
13+
plugin logger into both call sites. Resolution behavior is byte-identical.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/rest": major
4+
"@objectstack/verify": patch
5+
"@objectstack/cli": patch
6+
---
7+
8+
feat(security)!: `api.requireAuth` now defaults to `true` — anonymous access to the data API is denied by default (ADR-0056 D2 flip)
9+
10+
**BREAKING.** The global `requireAuth` default flipped FROM `false` TO `true`
11+
(`RestApiConfigSchema.requireAuth` in `@objectstack/spec`, mirrored by
12+
`RestServer.normalizeConfig` in `@objectstack/rest`). Anonymous requests to
13+
the `/data/*` CRUD + batch endpoints are now rejected with HTTP 401 unless the
14+
deployment explicitly opts out. (Scope note: this gate covers the REST
15+
`/data/*` surface — the metadata read/write endpoints and the dispatcher
16+
GraphQL route have their own pre-existing anonymous posture, tracked
17+
separately; this flip does not change them.)
18+
19+
**Migration (one line):** a deployment that intentionally serves data publicly
20+
(demo / playground / kiosk) sets the flag on the stack config — now a declared
21+
`ObjectStackDefinitionSchema.api` field, so it survives `defineStack` strict
22+
parsing (previously an undeclared top-level `api` key was silently stripped):
23+
24+
```ts
25+
export default defineStack({
26+
//
27+
api: { requireAuth: false },
28+
});
29+
```
30+
31+
The REST plugin logs a boot warning for the explicit opt-out so a fail-open
32+
posture is always visible. A misplaced `api.requireAuth` at the plugin level
33+
(one nesting short) is now also called out with a boot warning instead of
34+
being silently ignored.
35+
36+
**What keeps working with no action:**
37+
38+
- **Share links** — validate their token, then read under a system context.
39+
- **Public forms** — self-authorizing via the declaration-derived
40+
`publicFormGrant` (create + read-back on the declared target object only);
41+
no `guest_portal` profile needed.
42+
- **Control plane**`/auth`, `/health`, `/discovery` are exempt.
43+
- **`objectstack serve` with an auth-less stack** — the CLI passes an explicit
44+
`requireAuth: false` for stacks whose tier set has no `auth` (nothing could
45+
authenticate against them), with the boot warning.

0 commit comments

Comments
 (0)