Skip to content

Commit 06bd4fc

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23050-s9-search-endpoints
2 parents 0ba4e56 + b9dbc3d commit 06bd4fc

36 files changed

Lines changed: 1528 additions & 105 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
feat(plugin-security): under a walled posture, the deployment-level acceptance of package defaults can be host configuration (`acceptedDefaultPermissionSets`, ADR-0131 D5) (#23385)
6+
7+
Clause-②: yes (widening)
8+
9+
**What was wrong.** On a walled (`group` / `isolated`) deployment with several organizations and none of slug `default`, the audience-binding suggestion record has no organization to own it. Every boot logged `[security] audience-binding suggestion sync failed (non-fatal)`, and the accept door answered `409 SUGGESTION_STATE` with the remedy "create the Default Organization (slug 'default')". That is a reserved organization for deployment-level rows, the shape ADR-0131 §1.5 rejects.
10+
11+
**What is new.** `SecurityPluginOptions.acceptedDefaultPermissionSets?: readonly string[]` is read only under a walled posture. When it is declared, possibly as `[]`:
12+
13+
- the `everyone` anchor derives from the baseline plus the declared sets. Each name passes the same checks an accepted suggestion does: the security catalog serves it under some package, it declares `isDefault: true`, and it carries no bit the `everyone` anchor forbids (ADR-0090 D7). A name that fails a check grants nothing and is reported, at `error` for a forbidden bit;
14+
- the `sys_audience_binding_suggestion` record is neither read nor written, so the boot no longer warns. Existing rows are counted once and reported, not deleted;
15+
- the record's list, accept and dismiss doors answer `409 SUGGESTION_STATE` naming the option.
16+
17+
```ts
18+
new SecurityPlugin({ acceptedDefaultPermissionSets: [] }); // nothing beyond the baseline
19+
new SecurityPlugin({ acceptedDefaultPermissionSets: ['crm_readonly'] }); // every member also holds crm_readonly
20+
```
21+
22+
A walled deployment that declares nothing keeps today's behaviour. When it has no organization to own the record, the refusal now names `acceptedDefaultPermissionSets` instead of "create the Default Organization".
23+
24+
**Refused loudly.** A value that is not a list of non-empty names is refused at construction. A declaration under the `single` posture is refused at `start()`: there the record owned by the Default Organization is the acceptance. The `single` posture is otherwise unchanged, including its refusal text.
25+
26+
**Exported for callers of the module's functions.** `readAcceptedEveryoneSets`, `syncAudienceBindingSuggestions` and `reconcileAudienceBindingSuggestions` take an optional trailing `DeploymentAcceptance` (`{ posture, acceptedDefaultPermissionSets }`), and `SuggestionDeps` gains `acceptedDefaultPermissionSets`. Omitted, they read the record as before. `WithheldAcceptance` gains `source: 'record' | 'host-configuration'`.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/service-storage': patch
4+
'@objectstack/objectql': patch
5+
---
6+
7+
Clause-②: no
8+
9+
fix(cli): `os storage orphans`, the `os secret rewrap` dry run and the `os migrate summary-nulls` dry run say they write no row or table, and that opening a SQLite file can still switch its journal mode, instead of saying nothing was written
10+
11+
**What was wrong.** Each of these runs boots through the SQL driver before it prints, and the driver's connect switches a SQLite file to the journal mode it asks for (WAL, unless `OS_DATABASE_SQLITE_JOURNAL_MODE=delete` is set in the command's own environment or the project's `.env`). Measured on a file still on a rollback journal, each run moved the header's journal-mode bytes (18 and 19, from 1,1 to 2,2) and its change counter, and changed no row or table, while printing "nothing was written". Nothing was lost.
12+
13+
**What changed.** Each sentence below now claims no row or table. Where the run opened a SQLite file, the report's line is followed by the note the other read-only arms already carry: "Opening a SQLite file can still switch its journal mode (to WAL by default), which changes its header only." On PostgreSQL or MySQL the line ends without it.
14+
15+
- `os storage orphans` (`formatStrandedOrphanInventory`, `@objectstack/service-storage`). FROM: "This pass is READ-ONLY: nothing was written, tombstoned or deleted." TO: "This pass is READ-ONLY: no row or table was written, tombstoned or deleted." and the note.
16+
- `os secret rewrap` (the dry run's note, `REWRAP_NOTES.dryRun`, also in `--json` `report.notes`). FROM: "Dry run: every attributed row was opened, re-sealed and verified in memory, and nothing was written. Re-run with --apply to write." TO: "… and no row or table was written. Re-run with --apply to write." The note already follows the dry run's closing line on a SQLite file, so this sentence carries none.
17+
- `os migrate summary-nulls` (the dry run that found NULL roll-ups, `formatSummaryBackfillReport`, `@objectstack/objectql`). FROM: "Dry run — nothing was written. Re-run with --apply to backfill." TO: "Dry run — no row or table was written. Re-run with --apply to backfill." and the note.
18+
19+
**Unchanged.** No exit code, flag, prompt, refusal or export signature changes, and no `--json` document gains a field; the note is printed in the text output only. What the driver does on connect is unchanged: once a file is in the mode the driver asks for, these runs leave it byte-identical.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
Clause-②: yes (widening)
6+
7+
The platform translation bundle (`PlatformTranslationDataSchema`) gains a slot for a settings
8+
`info_banner` row: `settings.<namespace>.banners.<banner_id>.title` and `.text`, keyed by the
9+
specifier's `id`, beside `groups`, `keys` and `actions`. `title` translates the specifier's `label`
10+
(the heading spelling `groups` already uses) and `text` translates its `bannerText` (markdown).
11+
Before this, a translated banner was refused with `unrecognized_keys`, so a banner reached every
12+
locale in its manifest English. The slot is strict: a borrowed `label`, `bannerText` or
13+
`description` leaf, or a singular `banner` group, is refused with the spelling it meant.
14+
15+
Two resolvers read it, `resolveSettingsBannerTitle` and `resolveSettingsBannerText`, from the
16+
`resolveSettings*` family: they walk the requested locale and the declared fallback chain, then
17+
return the manifest literal. The `info_banner` specifier's own accept set is unchanged. Give the
18+
banner an `id` to make it addressable; a banner without one keeps rendering its literals.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): an explicit `sqlite` or `sqlite-wasm` driver selection refuses a database URL that is not a file path, instead of opening a SQLite file at a path made of the URL
6+
7+
Clause-②: no
8+
9+
An explicit selection (`OS_DATABASE_DRIVER`, `--database-driver`, or the `databaseDriver` option) skips the URL classification, so the only check its URL meets is the SQLite file-name rule, `sqliteFilenameFromUrl`. Both hosts call that rule: the runtime's standalone host (`os dev`, `os start` and `os serve` over a plain config or an artifact, every `os migrate` subcommand, every embedder of `createStandaloneStack`) and the CLI's legacy `os serve` path, which re-raises its refusal as the fatal `UnsupportedDriverError`.
10+
11+
- **Before, an opaque scheme (two or more characters and a `:` with no `//`).** `OS_DATABASE_DRIVER=sqlite OS_DATABASE_URL=jdbc:postgresql://127.0.0.1:5999/db os serve --dev` printed `✓ Server is ready` and created the directories `./jdbc:postgresql:/127.0.0.1:5999/`. `redis:h` opened a file named `redis:h`. This happened under both kinds.
12+
- **Before, a server URL under `sqlite-wasm`.** The WASM kind refused nothing: `OS_DATABASE_DRIVER=sqlite-wasm` beside `OS_DATABASE_URL=postgres://USER:PASSWORD@127.0.0.1:5999/db` booted ready and created `./postgres:/USER:PASSWORD@127.0.0.1:5999/`, which put the credentials in a directory name. `redis://h/x.db` opened a file too.
13+
- **After.** Under either kind, all of these get the refusal the `sqlite` kind already gave a `postgres://` URL. The kind is named in the sentence: `[StandaloneStack] sqlite-wasm driver was selected but the URL does not look like a file path (its scheme is "postgres://"). Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.` The message names the scheme only, never the URL, so credentials in the URL are not printed. The boot stops before it creates anything on disk.
14+
- **One file name changes answer.** Under an explicit selection a first path segment that holds a colon now reads as a scheme, as it already does when the URL selects the driver: `backup:2026.db` was a SQLite file and is now refused as the scheme `backup:`. Write `./backup:2026.db` or `file:backup:2026.db` for that file. A one-letter drive is a path, not a scheme (`C:\data\app.db`, `C:/data/app.db`).
15+
- **A `sqlite:` alias on a rung that does not normalize it.** `sqlite:` and `sqlite://` are aliases of `file:` in `OS_DATABASE_URL`, `--database` and the `databaseUrl` option. A declared default datasource's `url` and `TURSO_DATABASE_URL` reach the rule as written. There, under an explicit selection, `sqlite:./x.db` is now refused as the scheme `sqlite:`, where it used to open a file named `sqlite:./x.db`. `sqlite:///…` was already refused there under `sqlite` and is now refused under `sqlite-wasm` as well.
16+
17+
`file:./x.db`, `./x.db`, `:memory:`, `wasm-sqlite:///path` (under `sqlite-wasm`), `sqlite:./x.db` in `OS_DATABASE_URL`, and paths with no scheme resolve to the same file as before. An empty name under `sqlite-wasm` is still `:memory:`.

‎content/docs/references/system/translation.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ Translation data for a single object
287287
| **groups** | `Record<string, { title?: string; description?: string }>` | optional | Group translations keyed by group key |
288288
| **keys** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Per-setting field translations keyed by setting key |
289289
| **actions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string }>` | optional | Action button translations keyed by action id |
290+
| **banners** | `Record<string, { title?: string; text?: string }>` | optional | Info banner translations keyed by the info_banner specifier id |
290291

291292

292293
---

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `system/` | 373 |
22+
| `system/` | 374 |

‎packages/cli/src/commands/migrate/recorded-by.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ async function confirm(question: string): Promise<boolean> {
6161
*
6262
* Dry run by default, like every other `os migrate` subcommand (#2186): a
6363
* bare invocation reports how many rows still carry the sentinel and writes
64-
* nothing. `--apply` runs the conversion through the ADR-0119 D2 migration
64+
* no row or table. `--apply` runs the conversion through the ADR-0119 D2 migration
6565
* journal, so each chunk is one transaction and an interrupted run is
6666
* recoverable via `os migrate resume`.
6767
*

‎packages/cli/src/commands/migrate/summary-nulls.test.ts‎

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
* (the unscoped run the ruling keeps byte-for-byte). The seams that would boot
1313
* a database or walk a real engine are replaced; the command's own parse and
1414
* control flow run for real.
15+
*
16+
* And what the human face says about the database: the dry run's read-only
17+
* boot can still switch a SQLite file's journal mode, which rewrites its
18+
* header and no row, so the report's dry-run line claims no row or table and,
19+
* on a SQLite target only, is followed by the note naming the switch. The
20+
* report's formatter is the real one from `@objectstack/objectql`.
1521
*/
1622

1723
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@@ -21,6 +27,7 @@ import MigrateSummaryNulls from './summary-nulls.js';
2127
import { bootSchemaStack } from '../../utils/schema-migrate.js';
2228
import { probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
2329
import { isExitSignal } from '../../utils/format.js';
30+
import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
2431
import { backfillSummaryNulls } from '@objectstack/objectql';
2532

2633
vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: vi.fn() }));
@@ -29,10 +36,15 @@ vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
2936
probeMigrationTarget: vi.fn(),
3037
}));
3138
vi.mock('../../utils/data-migration-plugins.js', () => ({ buildDataMigrationPlugins: vi.fn(async () => []) }));
32-
vi.mock('@objectstack/objectql', () => ({
33-
backfillSummaryNulls: vi.fn(),
34-
formatSummaryBackfillReport: vi.fn(() => []),
35-
}));
39+
// The walk is replaced; the formatter is the real one, so the human-face pins
40+
// below read the report's own dry-run line.
41+
vi.mock('@objectstack/objectql', async (importOriginal) => {
42+
const real = await importOriginal<typeof import('@objectstack/objectql')>();
43+
return {
44+
backfillSummaryNulls: vi.fn(),
45+
formatSummaryBackfillReport: real.formatSummaryBackfillReport,
46+
};
47+
});
3648

3749
const HERE = dirname(fileURLToPath(import.meta.url));
3850
const CLI_ROOT = resolve(HERE, '..', '..', '..');
@@ -154,3 +166,53 @@ describe('os migrate summary-nulls', () => {
154166
expect(payload.error).toContain('customer.nope');
155167
}, RUN_TIMEOUT);
156168
});
169+
170+
// ───────────────────────────────────────────────────────────────────────────
171+
// The human face: what the dry run says about the database
172+
// ───────────────────────────────────────────────────────────────────────────
173+
174+
const SQLITE_CONFIG = { client: 'better-sqlite3', connection: { filename: '/srv/app/.objectstack/data/objectstack.db' } };
175+
const POSTGRES_CONFIG = { client: 'pg', connection: 'postgres://db.example.com:5432/app' };
176+
const DRY_RUN_LINE = 'Dry run — no row or table was written. Re-run with --apply to backfill.';
177+
178+
/** A dry run that found one NULL roll-up, so the report closes with its dry-run line. */
179+
const DRY_REPORT_WITH_NULLS = {
180+
...EMPTY_REPORT,
181+
scannedObjects: ['customer'],
182+
scannedRecords: 1,
183+
fields: [{
184+
object: 'customer', field: 'open_count', fn: 'count', childObject: 'case',
185+
nullRows: 1, nonEmpty: 0, filled: 0, sampleRecordIds: ['c_1'],
186+
}],
187+
nullRows: 1,
188+
};
189+
190+
describe('os migrate summary-nulls — the dry run names no write it cannot vouch for', () => {
191+
/** Boot onto a primary driver with `config`; every report line the run printed. */
192+
async function runDry(config: Record<string, unknown>): Promise<string[]> {
193+
vi.mocked(bootSchemaStack).mockResolvedValue({
194+
// The primary datasource's driver, as `SchemaStack.driver` carries it.
195+
driver: { config },
196+
kernel: { getService: () => engine },
197+
dbLabel: 'test database',
198+
shutdown: vi.fn(async () => {}),
199+
} as any);
200+
vi.mocked(backfillSummaryNulls).mockResolvedValue(DRY_REPORT_WITH_NULLS as any);
201+
await MigrateSummaryNulls.run([], { root: CLI_ROOT });
202+
return log.mock.calls.map((c: unknown[]) => c.map(String).join(' ')).join('\n').split('\n');
203+
}
204+
205+
it('on a SQLite file the dry-run line claims no row or table and names the journal-mode switch', async () => {
206+
const lines = await runDry(SQLITE_CONFIG);
207+
const dryRun = lines.find((line) => line.startsWith('Dry run —'));
208+
expect(dryRun, lines.join('\n')).toBe(`${DRY_RUN_LINE} ${JOURNAL_MODE_SWITCH_NOTE}`);
209+
expect(lines.join('\n')).not.toContain('nothing was written');
210+
}, RUN_TIMEOUT);
211+
212+
it('on a target with no file header (PostgreSQL) the dry-run line claims no row or table and names no switch', async () => {
213+
const lines = await runDry(POSTGRES_CONFIG);
214+
const dryRun = lines.find((line) => line.startsWith('Dry run —'));
215+
expect(dryRun, lines.join('\n')).toBe(DRY_RUN_LINE);
216+
expect(lines.join('\n')).not.toContain(JOURNAL_MODE_SWITCH_NOTE);
217+
}, RUN_TIMEOUT);
218+
});

‎packages/cli/src/commands/migrate/summary-nulls.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import { bootSchemaStack } from '../../utils/schema-migrate.js';
1919
import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
2020
import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
21+
import { describeDriverSqliteFile } from '../../utils/connection-display.js';
2122
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
2223
import { refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js';
2324
// Type-only, so the heavy engine package is still loaded lazily below: this is
@@ -254,7 +255,19 @@ export default class MigrateSummaryNulls extends Command {
254255

255256
printInfo(`Database: ${chalk.white(stack.dbLabel)}`);
256257
console.log('');
257-
console.log(formatSummaryBackfillReport(report).join('\n'));
258+
// [#23726] A dry run that found NULL roll-ups ends the report with "Dry
259+
// run — no row or table was written." (its last line). The read-only
260+
// boot's connect may still have switched a SQLite file's journal mode
261+
// (`SqlDriver.applySqliteJournalMode`, its rule for every connect), which
262+
// rewrites the file header and no row, so on a SQLite file that line is
263+
// followed by the note naming the switch. A target that is no SQLite file
264+
// (PostgreSQL, MySQL) has no header to switch. The `--json` document
265+
// carries no sentence and is unchanged.
266+
const journalNote = !report.applied && report.nullRows > 0
267+
&& describeDriverSqliteFile(stack.driver?.config) !== undefined
268+
? ` ${JOURNAL_MODE_SWITCH_NOTE}`
269+
: '';
270+
console.log(`${formatSummaryBackfillReport(report).join('\n')}${journalNote}`);
258271
console.log('');
259272

260273
if (report.failures.length > 0) {

‎packages/cli/src/commands/secret/orphans.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ export default class SecretOrphans extends Command {
218218
// [#21471] Composed through the one-shot helper, never with the
219219
// service's default provider: in a development posture with no key,
220220
// that default mints a key file in the key home, and this report
221-
// promises to write nothing.
221+
// promises to write no row or table, and writes no key file either.
222222
extraPlugins: [new PlatformObjectsPlugin(), await oneShotSettingsPlugin()],
223223
// [#21391] The report boots READ-ONLY, the boot `os migrate plan`
224224
// takes: `deferSchemaDdl` holds schema DDL back on every SQL

0 commit comments

Comments
 (0)