Skip to content

Commit 354935b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/execute-aggregate-execution-context-cukwsp
# Conflicts: # packages/services/service-analytics/src/analytics-service.ts # packages/services/service-analytics/src/dimension-labels.ts # packages/services/service-analytics/src/plugin.ts
2 parents 67c716c + 415254c commit 354935b

47 files changed

Lines changed: 2140 additions & 93 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.

.changeset/analytics-execute-aggregate-execution-context.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,19 @@ plugin-security has only the analytics layer. This is depth, not a replacement.
2828
ExecutionContext`. **Custom bridges should forward it** to their engine; the
2929
built-in auto-bridge does. Purely additive — an existing bridge that ignores
3030
it keeps working exactly as before.
31-
- `DimensionLabelDeps.fetchRecordLabels` gains an optional trailing `context`,
32-
and `resolveDimensionLabels` an optional trailing `context`.
31+
- `DimensionLabelDeps.fetchRecordLabels` and `resolveDimensionLabels` each gain
32+
an optional trailing `context`, beside the `scope` / `resolveScope` that
33+
#3639 added — the same two-belt split as the aggregate path.
3334
- `BootOptions.analytics` (`@objectstack/verify`) overrides the
3435
AnalyticsServicePlugin instance, so a gate can boot with the analytics belt
3536
off and assert the engine-side belt alone still scopes.
3637

3738
**Also fixed on the same seam:**
3839

3940
- `fetchRecordLabels` — the dimension display-label lookup — is row-granular
40-
(one row per record, real display names) and ran with neither read scope nor
41-
context. Its `ids` are confined to a scoped aggregate's output today, so it
42-
leaked nothing, but that was the caller's invariant, not the bridge's. It now
43-
ANDs the target object's read scope in and forwards the context.
41+
(one row per record, real display names). #3639 gave it the analytics-layer
42+
belt (the referenced object's own read scope); it now also carries the
43+
context, so the engine scopes the same read independently.
4444
- `ObjectQLStrategy.generateSql` emitted no `WHERE` at all, so the
4545
`/analytics/sql` preview read as an unscoped table scan while the real
4646
aggregate was scoped. It now renders the caller's filters and the read scope.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(analytics): scope the dimension-label lookup to the referenced object's RLS (#3602)
6+
7+
When a dataset groups by a `lookup`/`master_detail` dimension, analytics resolves
8+
the grouped FK ids to the related record's display name via a per-record read
9+
(`group by id`) dressed as an aggregate. That read carried **no read scope**, so
10+
it revealed related-record display names whenever the referenced object's RLS is
11+
stricter than the base object whose rows carry the id — a user could see a name
12+
the referenced object's own RLS would hide. (Same-object and looser-referenced
13+
cases were already safe because the ids come from the post-#3597 scoped
14+
aggregate; this closes the stricter-referenced case.)
15+
16+
The label lookup now applies the **referenced object's own** read scope — bound
17+
to the request via the same `getReadScope` provider the aggregate path uses,
18+
composed with `$and` (never key-merge) so it can't be displaced by the id
19+
predicate. Fail-closed: if that object's scope can't be resolved, the dimension's
20+
labels are skipped (the raw id renders) rather than fetched unscoped. No behaviour
21+
change when no read-scope provider is configured.
22+
23+
Internal `DimensionLabelDeps.fetchRecordLabels` gains an optional `scope` argument
24+
and `resolveDimensionLabels` an optional `resolveScope` resolver; both are
25+
service-analytics-internal (no spec/contract change).
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
Docs-only: development assessment routing the reference-integrity / option-key lint work for app metadata (issue #3583). Releases nothing.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/plugin-auth": patch
4+
---
5+
6+
fix(auth): provision the better-auth 1.7 columns `sys_team` / `sys_team_member` / `sys_two_factor` were missing (#3624)
7+
8+
better-auth 1.7.0-rc.1 added fields to three models that the platform objects
9+
never provisioned and `auth-schema-config.ts` never mapped. Because an unmapped
10+
field keeps its camelCase name, the adapter emitted columns no table had:
11+
12+
| model | field | column now provisioned |
13+
|:---|:---|:---|
14+
| `team` | `memberCount` | `sys_team.member_count` |
15+
| `teamMember` | `membershipKey` | `sys_team_member.membership_key` |
16+
| `twoFactor` | `failedVerificationCount` / `lockedUntil` | `sys_two_factor.failed_verification_count` / `locked_until` |
17+
18+
The team pair broke org creation outright. The organization plugin's team
19+
sub-feature is on by default, so `POST /api/v1/auth/organization/create`
20+
auto-creates a default team — and that insert died with `table sys_team has no
21+
column named memberCount` *after* the organization row had already committed.
22+
Callers got an HTTP 500 on top of a half-created org: a real org row with no
23+
default team behind it. Every multi-org deployment's create-org flow hit this.
24+
25+
The two-factor pair broke the 2FA lockout path the same way: better-auth
26+
guard-increments `failedVerificationCount` on each wrong code and stamps
27+
`lockedUntil` past the threshold, so a wrong code 500'd instead of being
28+
counted. All four columns are better-auth's own state — provisioned, readable,
29+
and never written from the ObjectStack side.
30+
31+
Existing environments pick the columns up through the driver's additive schema
32+
sync; no data migration is needed. `member_count` backfills to 0 and
33+
better-auth's own `syncTeamMemberCount` reconciles it on the next membership
34+
change, and `membership_key` stays null on pre-upgrade rows, which better-auth
35+
tolerates by falling back to the `(team_id, user_id)` pair.
36+
37+
A new drift gate (`better-auth-schema-parity.test.ts`) now asserts that every
38+
column the installed better-auth version can write exists on the platform
39+
object backing it, across the auth manager's whole model surface. The ADR-0092
40+
D7 guard only ever caught *collisions* between our extension fields and
41+
better-auth's, so a bump that adds a brand-new field passed the build and failed
42+
at runtime — twice now, counting the 1.7 `oauthAccessToken.authorizationCodeId`
43+
regression. The next one fails the build instead.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): export emits the projected header row on an empty result set (#3547)
6+
7+
`GET /data/:object/export` wrote a zero-byte file whenever the query matched no
8+
rows — the header was only ever written alongside the first data chunk. With the
9+
`getReadableFields` column projection the readable column set is derived from
10+
schema + context, so it is known even when no rows come back: an empty CSV/xlsx
11+
export now carries the exact readable header, which also makes it a usable
12+
import template.
13+
14+
The header is emitted only when the column set is AUTHORITATIVE — the security
15+
service's readable projection, or an explicit `?fields=` request. When the header
16+
is schema-derived and the projection was unavailable, the export stays headerless
17+
as before: the masked-row fallback has no rows to narrow with, and writing the
18+
full schema header would name FLS-hidden columns. `header=false` still suppresses
19+
the header in every case.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
feat(auth): `onInvitationAccepted` host seam — better-auth's
6+
`afterAcceptInvitation` forwarded to the host (ADR-0105 D8 prerequisite)
7+
8+
An invitation may carry placement intent (target business unit + positions,
9+
extension fields on `sys_invitation` per the ADR-0092 whitelist), but there
10+
was no server-side seam to apply it when the invitation is accepted —
11+
better-auth's org-plugin models don't fire core `databaseHooks` (framework
12+
#3541 D8 note).
13+
14+
`AuthManagerConfig.onInvitationAccepted` mirrors `onOrganizationCreated`:
15+
invoked from `organizationHooks.afterAcceptInvitation` with the mapped ids
16+
(`invitationId`, `organizationId`, `userId`, `memberId`, `role`, `email`)
17+
plus the RAW `invitation` / `member` rows so a host reads its own extension
18+
columns without a second query. Failure-isolated — acceptance never rolls
19+
back on a side-effect miss; hosts needing effectively-atomic placement
20+
should make the callback idempotent and reconcile on retry.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/service-storage": patch
3+
"@objectstack/service-i18n": patch
4+
"@objectstack/client": patch
5+
---
6+
7+
fix(client,service-i18n): ledger the autonomously-mounted service routes, and repair the two i18n calls that reached nothing (#3636)
8+
9+
Tranche 3 of the #3563 route audit — the last un-audited server surface. The
10+
dispatcher ledger (#3563) and the REST ledger (#3587) each stop at their own
11+
package boundary, and two services mount routes outside both: they reach for
12+
the `http-server` service and register straight on `IHttpServer`, so neither
13+
`RouteManager` nor `RestServer.getRoutes()` has ever seen them. That left the
14+
SDK's entire storage surface, plus all of i18n, in the pre-#3563 posture:
15+
expressed, working, guarded by nothing.
16+
17+
**Ledgers + guards.** `storage-route-ledger.ts` (10 routes) and
18+
`i18n-route-ledger.ts` (3) sit next to the registrars that mount them, each
19+
enumerated for real — the registrar runs against a capturing mock
20+
`IHttpServer` and its registration calls *are* the route set, so a new route
21+
lands with a reviewed disposition or fails CI. The client half is
22+
`packages/client/src/service-route-ledger-coverage.test.ts`; ledgers cross the
23+
boundary as relative source imports, never a service→client package edge.
24+
25+
**Two wire-level 404s fixed.** `i18n.getTranslations` sent
26+
`/i18n/translations?locale=xx` and `i18n.getFieldLabels` sent
27+
`/i18n/labels/:object?locale=xx`, while every serving surface — service-i18n's
28+
mounts, the dispatcher's HTTP mounts, and the `plugin-rest-api.zod.ts`
29+
contract — mounts only the path form. Neither call could ever be answered.
30+
Both had carried a green `sdk` row in the dispatcher ledger since tranche 1,
31+
because that guard asks whether the client *method* exists, not whether it
32+
speaks a URL anything mounts. The client now sends the path dialect, the same
33+
resolution #3611 gave `meta.getView`, and a new suite drives the real client
34+
at a real router so a revert cannot pass quietly.
35+
36+
**One response-shape fix.** service-i18n's success bodies omitted the
37+
`success` flag that `ObjectStackClient.unwrapResponse` keys on, so the SDK
38+
returned the raw `{ data: … }` wrapper against that provider while returning
39+
the declared unwrapped shape against the dispatcher — one method, two shapes,
40+
decided by which plugin mounted the route. Its three handlers now emit the
41+
`{ success: true, data }` envelope the `i18n` route group declares. `data` did
42+
not move, so direct body readers are unaffected.
43+
44+
Storage audited clean: 7 routes SDK-expressed, 3 reviewed `server-only` (the
45+
browser capability URL objectql stamps into file-field payloads, and the two
46+
local-driver loopbacks). The chunked-upload family, flagged for triage, turned
47+
out fully expressed. Both ledgers ratchet `gap` and `mismatch` at zero.
48+
49+
Filed, not fixed: `GET {base}/_local/file/:key` is built by three call sites
50+
and mounted by none (#3641); the cross-surface URL conformance guard that would
51+
have caught all of the above mechanically is the capstone (#3642).
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
feat(showcase): add the action `disabled`-predicate specimen (`showcase_archive_task`) — greyed until `record.done`, contrasting `visible` (hide) with `disabled` (grey). Examples-only; releases nothing.

.github/workflows/ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,35 @@ jobs:
247247
echo "::endgroup::"
248248
done
249249
250+
dogfood-gate:
251+
# Stable required-check name for a SHARDED job (#3622 follow-up).
252+
#
253+
# Branch protection requires the context "Dogfood Regression Gate". Once
254+
# the job became a 2-way matrix its checks publish as "Dogfood Regression
255+
# Gate (1/2)" / "(2/2)" — the bare context could never appear again, so
256+
# EVERY pull request in the repo sat permanently BLOCKED (mergeable, all
257+
# checks green, merge button dead). #3622's own comment called for updating
258+
# branch protection; keeping the contract HERE instead means a future
259+
# shard-count change cannot deadlock the repo a second time.
260+
#
261+
# `if: always()` + result inspection so a legitimately skipped matrix (the
262+
# `filter` job says no core paths changed) still satisfies the gate.
263+
name: Dogfood Regression Gate
264+
needs: dogfood
265+
if: always()
266+
runs-on: ubuntu-latest
267+
permissions:
268+
contents: read
269+
steps:
270+
- name: Verify dogfood shard results
271+
run: |
272+
result="${{ needs.dogfood.result }}"
273+
echo "dogfood matrix aggregate result: $result"
274+
case "$result" in
275+
success|skipped) echo "Dogfood gate satisfied." ;;
276+
*) echo "::error::Dogfood shards did not pass (aggregate result: $result)"; exit 1 ;;
277+
esac
278+
250279
build-core:
251280
name: Build Core
252281
needs: filter

0 commit comments

Comments
 (0)