Skip to content

Commit ca06c67

Browse files
committed
Merge origin/main into claude/issue-25128-tenant-column-never-null-comments
2 parents 3c3a575 + 1b3b65e commit ca06c67

49 files changed

Lines changed: 2429 additions & 1000 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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
'@objectstack/driver-turso': minor
4+
'@objectstack/driver-memory': minor
5+
'@objectstack/objectql': minor
6+
'@objectstack/metadata-protocol': minor
7+
'@objectstack/cli': minor
8+
---
9+
10+
feat(driver-sql,driver-turso,driver-memory,objectql,metadata-protocol,cli): an organization-scoped unique index keys the plain organization column, and an index an earlier release built with `COALESCE(organization_id, '__global__')` is read as the same index on a certified database
11+
12+
Clause-②: yes (widening)
13+
Metadata-protocol: semantic
14+
15+
18.x step 7b-1 + 7b-2 (ADR-0131 D1: the tenant column is NOT NULL, so the NULL-organization bucket of ADR-0120 D3 has no rows left to hold).
16+
17+
- **New DDL is plain.** A field-level `unique: true` / `'organization'` on a tenant-scoped object, and a declared index with `unique: 'organization'`, now build `UNIQUE (organization_id, …)` instead of `UNIQUE (COALESCE(organization_id, '__global__'), …)`. Index names do not change. The same holds for the Turso remote face, the MySQL hash shadow (its generated column now hashes the plain column), the in-memory driver's unique enforcement, `os generate migration` (which now emits the organization composite it used to list as NOT EMITTED), and the `sys_setting` row-identity index (`COALESCE(user_id, '')` stays).
18+
- **Existing indexes stay as they are.** Nothing is renamed, and no declared organization composite is dropped or rebuilt by the boot, `os migrate plan` or `os migrate apply`; on SQLite the ownership ceremony's NOT NULL step rebuilds the tables it tightens, and their composites come back plain, enforcing the same constraint. One index is re-keyed: the `sys_setting` row-identity index, which the metadata protocol rebuilds on every boot, keys the plain `organization_id` from the next boot on (probe-first; a pure relaxation, so it cannot fail on existing rows). The schema differ reads `COALESCE(<tenant>, …)` and the plain tenant column as one key part, in both directions, where the column is physically `NOT NULL` or the database is certified (its organization-ownership birth marker, or the ownership ceremony's marker at the current shape generation). So `os migrate plan` and the boot report no `recreate_index` for those indexes. On an uncertified database with a nullable tenant column the two forms stay different constraints: the finding is reported, and it names `os migrate organization-ownership` as its remedy.
19+
- **What widens.** Where a tenant column is still nullable (every table of a database 18.0.0 created, an uncertified 17.x / 18.0 database, a rotation shard, a Turso remote table, a table the ceremony reported), a unique index created from now on does not constrain rows whose organization is NULL, exactly like a NULL in any other key part; in the in-memory driver a record with no organization is exempt from an organization-scoped unique. The unique duplicate pre-flight no longer reports groups of rows with a NULL key part, since the index never refuses them.
20+
- **Who sees it.** A fresh install and every ceremonied table whose tenant column is NOT NULL see nothing. A database 18.0.0 created from empty is certified by its birth marker yet keeps nullable tenant columns: its existing indexes are kept unchanged, and a new organization unique declared on one of its tables constrains only rows that carry an organization.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
feat(rest)!: `GET {base}/docs`, the OpenAPI document's HTML viewer page, is retired; `GET {base}/openapi.json` stays
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) A runtime route retirement in RestServer.registerOpenApiEndpoints, not a metadata change. No spec key, export, option, config field, response field or stored shape is removed, renamed or re-shaped: the page was an HTML answer on one REST path, and `objectstack migrate meta` has nothing to rewrite. The machine-readable document, its `api.enableOpenApi` gate and its answer are unchanged. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this route and this diff adds none (not registered / already-registered); and no published interface or type changes (not runtime-interface-only / type-surface-only / runtime-export-retired). -->
11+
12+
**BREAKING** (a published route stops being served), shipped as `patch` because it is a non-protocol change: it changes what a REST caller is served, not what a metadata author can write.
13+
14+
`RestServer.registerOpenApiEndpoints` mounted two routes on every base: the OpenAPI document and an HTML viewer page for it. The viewer page is retired on both route shapes, the unscoped `{base}/docs` and the environment-scoped `{base}/environments/:environmentId/docs`. Nothing read it, and the API origin serves no HTML viewer.
15+
16+
- **Not served now:** `GET {base}/docs` has no route. A request to it gets the host's ordinary not-found answer, the same one any unmounted path gets: on the standard server (`@objectstack/plugin-hono-server`) that is `404` with code `ENDPOINT_NOT_FOUND`, for a signed-in caller and an anonymous one alike.
17+
- **Unchanged:** `GET {base}/openapi.json` on both route shapes, its `api.enableOpenApi` gate, its anonymous `401` and the document a signed-in caller receives. The document no longer lists the `/docs` path, because it describes only mounted routes.
18+
- `api.enableOpenApi` now gates one route, the document.
19+
20+
What changes for you. If you opened the page, load the document from `GET {base}/openapi.json` into an OpenAPI viewer of your choice.

‎docs/qa/platform-checklist/areas/access-security.json‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3434,10 +3434,10 @@
34343434
},
34353435
{
34363436
"id": "access-security.anonymous-floor-v18-doors",
3437-
"title": "The anonymous floor answers 401 FIRST on the doors v18 added to it — /notifications (was 501), non-POST /keys (was 405), share-link management (was 501), /openapi.json and /docs, every /i18n face and the settings family — while the token faces and the public-form submit stay anonymous and the submit echoes only {id}",
3437+
"title": "The anonymous floor answers 401 FIRST on the doors v18 added to it — /notifications (was 501), non-POST /keys (was 405), share-link management (was 501), /openapi.json, every /i18n face and the settings family — while the token faces and the public-form submit stay anonymous and the submit echoes only {id}",
34383438
"since": "v18",
34393439
"status": "active",
3440-
"revision": 2,
3440+
"revision": 3,
34413441
"priority": "P1",
34423442
"surface": "api",
34433443
"personas": [
@@ -3456,7 +3456,8 @@
34563456
]
34573457
},
34583458
"steps": [
3459-
"with NO credentials call: GET /api/v1/notifications, GET /api/v1/keys, GET /api/v1/share-links, GET /api/v1/openapi.json, GET /api/v1/docs, GET /api/v1/i18n/locales, GET /api/settings/branding",
3459+
"with NO credentials call: GET /api/v1/notifications, GET /api/v1/keys, GET /api/v1/share-links, GET /api/v1/openapi.json, GET /api/v1/i18n/locales, GET /api/settings/branding",
3460+
"with NO credentials, then as admin, call the retired viewer path GET /api/v1/docs, and a path nothing serves (GET /api/v1/this-route-does-not-exist) as the control",
34603461
"with no credentials call a token face: GET /api/v1/share-links/BOGUS/resolve",
34613462
"with no credentials POST /api/v1/forms/contact-us/submit with the four whitelisted fields plus a forged owner_id and a non-whitelisted status",
34623463
"repeat two of the 401 calls as admin to prove the door itself serves"
@@ -3474,6 +3475,12 @@
34743475
"verify": "status + error.code per door, anonymous",
34753476
"evidence": "one status+code line per door"
34763477
},
3478+
{
3479+
"clause": "the retired viewer path GET /api/v1/docs is routed nowhere: to the anonymous caller and to the admin it answers 404 with the same body the never-served control path answers on that boot — never 401 (a door is back) and never 200 or text/html (the page is back)",
3480+
"oracle": "api",
3481+
"verify": "status + content-type + body of GET /api/v1/docs, anonymous and as admin, compared with the control path's",
3482+
"evidence": "one status+code line per call, the control included"
3483+
},
34773484
{
34783485
"clause": "the share-link token face is NOT behind the floor (it answers its own invalid-token refusal, not 401)",
34793486
"oracle": "api",
@@ -3496,7 +3503,7 @@
34963503
],
34973504
"automated": {
34983505
"kind": "dogfood",
3499-
"ref": "packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts + packages/rest/src/rest-api-description-anonymous-deny.test.ts + packages/runtime/src/domains/i18n-anonymous-deny.test.ts + packages/services/service-settings/src/settings-routes.anonymous-floor.pin.test.ts + packages/rest/src/public-form-submit-answer.test.ts"
3506+
"ref": "packages/qa/dogfood/test/anonymous-door-sweep.dogfood.test.ts + packages/rest/src/rest-api-description-anonymous-deny.test.ts + packages/rest/src/rest-docs-viewer-retired.pin.test.ts + packages/runtime/src/domains/i18n-anonymous-deny.test.ts + packages/services/service-settings/src/settings-routes.anonymous-floor.pin.test.ts + packages/rest/src/public-form-submit-answer.test.ts"
35003507
},
35013508
"source": [
35023509
"packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous",
@@ -3516,6 +3523,12 @@
35163523
"date": "2026-10-19",
35173524
"change": "fixtures.knownGaps[1] marked CLOSED — a checklist-accuracy finding of the 18.0 QA sweep (QA run #24559, access-security.anonymous-floor-v18-doors; wave anchor #24579 §3): the gap said a non-POST /keys never reaches the floor on a stock boot and answers 405; since #23909 the dispatcher registers /keys for every verb (packages/runtime/src/dispatcher-plugin.ts) and the run measured anonymous GET /api/v1/keys → 401. No clause changed: acceptance[1] already demanded 401",
35183525
"ref": "#24632"
3526+
},
3527+
{
3528+
"revision": 3,
3529+
"date": "2026-10-21",
3530+
"change": "re-pointed for the retirement of GET /api/v1/docs, the OpenAPI document's HTML viewer page (no consumer; the API origin serves no HTML viewer). /docs left the title and the floored-door step, so acceptance[1] no longer demands 401 of it; a new step and acceptance[2] drive the retired path anonymously and as admin and demand the host's ordinary 404, the never-served control path's body, on that boot. automated.ref gains packages/rest/src/rest-docs-viewer-retired.pin.test.ts, the registrar-level not-served pin on both route shapes. /openapi.json stays on the floor, unchanged",
3531+
"ref": "#25104"
35193532
}
35203533
]
35213534
},

0 commit comments

Comments
 (0)