Skip to content

Commit 381f43d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-fla9sj
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jTEjfqGjVA7q5WXAGd
2 parents 84beb52 + 95df345 commit 381f43d

21 files changed

Lines changed: 934 additions & 43 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(cli,runtime): the organization-ownership refusal names the database for `os dev -d`, and the driver and token `os start` / `os dev` were given by flag
7+
8+
Clause-②: yes (widening) — `@objectstack/runtime`'s published `OrganizationOwnershipBootGuardOptions` gains two optional members, `databaseDriver` and `databaseAuthTokenFromFlag`, and the refusal's two ceremony commands set `OS_DATABASE_DRIVER` and `OS_DATABASE_AUTH_TOKEN` in front when `--database-driver` / `--database-auth-token` chose them; `os dev`'s `--dev` commands carry `--database-url` when `-d` chose the database. What `os dev`, `os start`, `os serve` and `os migrate organization-ownership` accept or refuse is unchanged.
9+
Metadata-protocol: none
10+
11+
`os dev -d URL` served the database `-d` chose, but when the organization-ownership boot guard refused it, the refusal printed `os migrate organization-ownership --dev --out PLAN` (and the `--apply` command) with no database, and `os dev`'s own line for a database it does not upgrade in place (anything that is not a local SQLite file) repeated that bare spelling. Run as printed, the ceremony planned the project's default database instead. `os dev` now hands its `serve` child the database it resolved through the same internal channel `os start` uses (#31488), so both commands read `os migrate organization-ownership --dev --database-url URL …`, a URL with a credential read from `"${OS_DATABASE_URL:?…}"` as before. `os dev`'s own line now points at the two commands the refusal names instead of spelling a bare one.
12+
13+
`os start` and `os dev` put `--database-driver` and `--database-auth-token` in the child's environment only (`OS_DATABASE_DRIVER`, `OS_DATABASE_AUTH_TOKEN`), and the ceremony reads those two variables (it has no flag for either), so the printed commands opened the database with another driver or without its token. They now set both in front of the command: `OS_DATABASE_DRIVER=turso OS_DATABASE_AUTH_TOKEN="${OS_DATABASE_AUTH_TOKEN:?…}" os migrate organization-ownership …`. The token is never printed and never reaches the guard: the command re-exports the shell's own `OS_DATABASE_AUTH_TOKEN`, the refusal says to export it, and the shell stops with that message when it is unset. A driver or token the operator exported themselves is in their shell already and is not repeated.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): a declared app endpoint runs the environment-membership check
6+
7+
Clause-②: yes (widening) — `@objectstack/runtime`'s published `HttpDispatcher` gains one public method, `environmentMembershipRefusal(context)`.
8+
Metadata-protocol: none
9+
10+
On a multi-environment host that enforces environment membership, a declared app endpoint (`/api/v1/apps/…`) now answers a signed-in caller who is not a member of the environment the request resolves to with `403 PROJECT_MEMBERSHIP_REQUIRED`, as the built-in routes do, whether or not the endpoint sets `authRequired`. The check runs after the endpoint is matched and its policies admitted the caller, so a path no declaration owns keeps its `404` and an anonymous caller of an `authRequired` endpoint its `401`. Members, anonymous callers, and hosts without environment scoping or with the check turned off (`api.enforceProjectMembership: false`) are unchanged. `HttpDispatcher` gains `environmentMembershipRefusal(context)`, and `runAppEndpointStep` an optional `membership` input.

‎packages/cli/src/commands/dev.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ import { claudeMcpAddLine, MCP_ENDPOINT_PATH } from './mcp/connect.js';
4242
// #12620 and #12662 protected against.
4343
import { describePortSource, parseRequestedPort, formatInvalidPortNotice, portFlagEnv } from '../utils/port-contract.js';
4444
import { applyCryptoKeyFileChannel } from '../utils/internal-crypto-key-file-channel.js';
45+
import {
46+
ceremonyDatabaseUrl,
47+
writeCeremonyConnectionChannel,
48+
writeCeremonyDatabaseChannel,
49+
} from '../utils/internal-ceremony-database-channel.js';
4550
import { startDevDeployKey, type DeployKeyHandle } from '../utils/dev-deploy-key.js';
4651
// The ONE dev-TLS contract, shared with the `serve` child this command spawns
4752
// (#16804). ⛔ Nothing about certificates is declared in this file, and ⛔ no
@@ -841,6 +846,17 @@ export default class Dev extends Command {
841846
...(flags['auth-secret'] ? { OS_AUTH_SECRET: flags['auth-secret'] } : {}),
842847
};
843848
applyFreshHomeChannel(localEnv, freshHome);
849+
// [#31547] The database `-d` (or `--fresh`) chose is one the ceremony the
850+
// ownership refusal names does not find on its own, and the driver and
851+
// token the two flags above put here are not in the developer's shell:
852+
// the child names them in both `--dev` commands, read from the
853+
// resolution above (the one this boot serves), never re-resolved. Same
854+
// channel as `os start` (#31488). `os dev` has no `--home`.
855+
writeCeremonyDatabaseChannel(localEnv, ceremonyDatabaseUrl(resolvedDb, { homeFlag: undefined }));
856+
writeCeremonyConnectionChannel(localEnv, {
857+
databaseDriver: flags['database-driver'],
858+
databaseAuthToken: flags['database-auth-token'],
859+
});
844860
// The data key's file: the ONE answer `os start` takes too (#29934), so a
845861
// secret saved here reads back under `os start` over the same project —
846862
// and the file an earlier `os start` kept in the project is still read.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { mountsAiServicePlugin, resolveAiServiceLoad, resolveOptionalPluginLoad
1414
import { registerInstalledPlugins } from '../utils/installed-plugins-boot.js';
1515
import { readInternalArtifactPath, readInternalConfigOutputPath } from '../utils/internal-artifact-channel.js';
1616
import { readAuthSecretFileChannel } from '../utils/internal-auth-secret-channel.js';
17-
import { readCeremonyDatabaseChannel } from '../utils/internal-ceremony-database-channel.js';
17+
import { readCeremonyConnectionChannel, readCeremonyDatabaseChannel } from '../utils/internal-ceremony-database-channel.js';
1818
import { compileForDevBoot, devCompilesAtBoot } from '../utils/dev-compile-at-boot.js';
1919
import { makeDirectorySync } from '../utils/make-directory.js';
2020
// The precedence's last rung — whether the cwd config takes part — decided by
@@ -4218,12 +4218,14 @@ export default class Serve extends Command {
42184218
// persisted in its home names that file, so the ceremony runs with the
42194219
// secret this server composes its security plugin with. A boot on a
42204220
// database the ceremony would not find by default (`os start -d` or
4221-
// `--home`, #31488) names it, so the commands plan this database.
4221+
// `--home`, #31488; `os dev -d`, #31547) names it, so the commands plan
4222+
// this database, with the driver and token its parent's flags chose.
42224223
const ceremonyDatabase = readCeremonyDatabaseChannel(process.env);
42234224
await kernel.use(new OrganizationOwnershipBootGuardPlugin({
42244225
dev: isDev,
42254226
authSecretFile: readAuthSecretFileChannel(process.env),
42264227
...(ceremonyDatabase ? { databaseUrl: ceremonyDatabase } : {}),
4228+
...readCeremonyConnectionChannel(process.env),
42274229
}));
42284230
trackPlugin('OrganizationOwnershipBootGuard');
42294231
}

‎packages/cli/src/commands/start.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ import {
2323
import { ServeRestartCoordinator } from '../utils/dev-restart.js';
2424
import { readEnvWithDeprecation } from '@objectstack/types';
2525
import { writeAuthSecretFileChannel } from '../utils/internal-auth-secret-channel.js';
26-
import { ceremonyDatabaseUrl, writeCeremonyDatabaseChannel } from '../utils/internal-ceremony-database-channel.js';
26+
import {
27+
ceremonyDatabaseUrl,
28+
writeCeremonyConnectionChannel,
29+
writeCeremonyDatabaseChannel,
30+
} from '../utils/internal-ceremony-database-channel.js';
2731
import { applyCryptoKeyFileChannel } from '../utils/internal-crypto-key-file-channel.js';
2832
import { resolveStartHome } from '../utils/start-home.js';
2933
// The ONE port contract, shared with `dev` and with the `serve` child this
@@ -507,6 +511,12 @@ export default class Start extends Command {
507511
// child names it in both commands, read from the resolution above — the
508512
// one this boot serves — never re-resolved.
509513
writeCeremonyDatabaseChannel(localEnv, ceremonyDatabaseUrl(resolvedDb, { homeFlag: flags.home }));
514+
// [#31547] The driver and the token the two flags above put in the child's
515+
// environment only: the commands set them in front, the token by reference.
516+
writeCeremonyConnectionChannel(localEnv, {
517+
databaseDriver: flags['database-driver'],
518+
databaseAuthToken: flags['database-auth-token'],
519+
});
510520
// NODE_ENV is only forced to production when the user has not set it.
511521
// Allows `NODE_ENV=development objectstack start` to work for debugging.
512522
//

‎packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
1414
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
1515
import { tmpdir } from 'node:os';
1616
import { join } from 'node:path';
17+
// The telemetry sibling's resolution reaches both through dynamic imports: pay their load at collection.
18+
import '@objectstack/runtime';
19+
import '@objectstack/service-datasource';
1720
import {
1821
isOrganizationOwnershipRequiredMessage,
1922
ORGANIZATION_OWNERSHIP_REQUIRED_MESSAGE,
@@ -105,8 +108,10 @@ describe('os dev — the in-place upgrade of a v17-shape development database',
105108
expect(result.lines[0]).toBe('Refused (sys_mystery): sys_mystery carries organization_id …');
106109
expect(result.lines.at(-1)).toContain('nothing was changed');
107110
expect(result.lines.at(-1)).toContain(BACKUP());
108-
// The ceremony is open (18.x step 6): the way on is running it by hand.
109-
expect(result.lines.at(-1)).toContain('os migrate organization-ownership --dev');
111+
// The ceremony is open (18.x step 6): the way on is running it by hand, by
112+
// the commands the guard's refusal printed (#31547: they name the database).
113+
expect(result.lines.at(-1)).toContain("the two commands the boot guard's refusal above names");
114+
expect(result.lines.at(-1)).not.toContain('os migrate organization-ownership --dev');
110115
expect(result.lines.at(-1)).not.toContain('17.x');
111116
});
112117

@@ -140,12 +145,16 @@ describe('os dev — the in-place upgrade of a v17-shape development database',
140145
expect(result.lines.at(-1)).toContain(`--apply --plan-file ${join(BACKUP(), 'plan.json')} --backup-taken`);
141146
});
142147

143-
it('a database that is not a local SQLite file is not upgraded in place: the ceremony is named instead', () => {
148+
it('⭐ #31547: a database that is not a local SQLite file is not upgraded in place: the refusal\'s commands are pointed at, never respelled bare', () => {
144149
const result = upgradeDevelopmentDatabase(input(fakeRun({}), { sqliteFile: null, databaseUrl: 'postgres://db/app' }));
145150
expect(result.ok).toBe(false);
146151
expect(calls).toEqual([]);
147152
expect(existsSync(join(dir, 'backups'))).toBe(false);
148-
expect(result.lines[0]).toContain('os migrate organization-ownership --dev');
153+
// The guard's refusal names this database (`--database-url`), its driver and
154+
// token; a bare `os migrate organization-ownership --dev` would plan the
155+
// project's default database instead.
156+
expect(result.lines[0]).toContain("the two commands the boot guard's refusal above names");
157+
expect(result.lines[0]).not.toContain('os migrate organization-ownership --dev');
149158
expect(result.lines[0]).not.toContain('17.x');
150159
});
151160

‎packages/cli/src/utils/dev-organization-ownership-upgrade.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,10 +227,15 @@ function discardedStamps(planFile: string): Array<{ object: string; rows: number
227227
*/
228228
export function upgradeDevelopmentDatabase(input: DevelopmentUpgradeInput): DevelopmentUpgradeResult {
229229
// The way on, when os dev does not upgrade the database itself: the ceremony,
230-
// run by hand (18.x step 6 opened it).
230+
// run by hand (18.x step 6 opened it). [#31547] By the two commands the
231+
// guard's refusal printed just above, never a second spelling of them here:
232+
// those name the database this boot opened (`-d`), with its driver and
233+
// token, and keep a credential out of the output; a bare
234+
// `os migrate organization-ownership --dev` plans the project's default
235+
// database instead.
231236
const manual =
232-
'Run `os migrate organization-ownership --dev` (review its plan), then the same command with --apply ' +
233-
'--plan-file FILE --backup-taken, and start os dev again.';
237+
"Run the two commands the boot guard's refusal above names, first the plan (review it), then the apply, " +
238+
'and start os dev again.';
234239
if (!input.sqliteFile) {
235240
return {
236241
ok: false,

‎packages/cli/src/utils/internal-ceremony-database-channel.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,16 @@
1919
* - the channel is owned by the parent: set when named, deleted otherwise.
2020
*/
2121

22+
import { readFileSync } from 'node:fs';
2223
import { describe, expect, it } from 'vitest';
2324
import {
25+
INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV,
26+
INTERNAL_CEREMONY_DATABASE_DRIVER_ENV,
2427
INTERNAL_CEREMONY_DATABASE_URL_ENV,
2528
ceremonyDatabaseUrl,
29+
readCeremonyConnectionChannel,
2630
readCeremonyDatabaseChannel,
31+
writeCeremonyConnectionChannel,
2732
writeCeremonyDatabaseChannel,
2833
} from './internal-ceremony-database-channel.js';
2934

@@ -63,3 +68,38 @@ describe('the ceremony-database channel to the serve child', () => {
6368
expect(readCeremonyDatabaseChannel({ [INTERNAL_CEREMONY_DATABASE_URL_ENV]: '' })).toBeUndefined();
6469
});
6570
});
71+
72+
describe('#31547 — the driver and the token flag, carried beside the URL', () => {
73+
it('⭐ --database-driver is carried by value, --database-auth-token only as "the flag gave it": its value is never written', () => {
74+
const env: NodeJS.ProcessEnv = {};
75+
writeCeremonyConnectionChannel(env, { databaseDriver: 'turso', databaseAuthToken: 'tok-s3cret-31547' });
76+
expect(env[INTERNAL_CEREMONY_DATABASE_DRIVER_ENV]).toBe('turso');
77+
expect(env[INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV]).toBe('1');
78+
expect(JSON.stringify(env)).not.toContain('s3cret');
79+
// Shaped as the guard's options, so `serve` spreads it as it is.
80+
expect(readCeremonyConnectionChannel(env)).toEqual({ databaseDriver: 'turso', databaseAuthTokenFromFlag: true });
81+
});
82+
83+
it('is owned by the parent: no flag, no variable — an inherited copy never speaks', () => {
84+
const env: NodeJS.ProcessEnv = {
85+
[INTERNAL_CEREMONY_DATABASE_DRIVER_ENV]: 'pg',
86+
[INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV]: '1',
87+
};
88+
writeCeremonyConnectionChannel(env, { databaseDriver: undefined, databaseAuthToken: undefined });
89+
expect(INTERNAL_CEREMONY_DATABASE_DRIVER_ENV in env).toBe(false);
90+
expect(INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV in env).toBe(false);
91+
expect(readCeremonyConnectionChannel(env)).toEqual({});
92+
// An empty flag value put nothing in the child's environment either (`start` / `dev` test truthiness).
93+
writeCeremonyConnectionChannel(env, { databaseDriver: '', databaseAuthToken: '' });
94+
expect(readCeremonyConnectionChannel(env)).toEqual({});
95+
});
96+
97+
it('⭐ `os dev` writes both channels from its own resolution, as `os start` does: one hand-off, no second resolver', () => {
98+
for (const command of ['start', 'dev']) {
99+
const source = readFileSync(new URL(`../commands/${command}.ts`, import.meta.url), 'utf8');
100+
expect(source, command).toMatch(/writeCeremonyDatabaseChannel\(localEnv, ceremonyDatabaseUrl\(resolvedDb, \{ homeFlag: /);
101+
expect(source, command).toMatch(/writeCeremonyConnectionChannel\(localEnv, \{\s*databaseDriver: flags\['database-driver'\],\s*databaseAuthToken: flags\['database-auth-token'\],/);
102+
}
103+
});
104+
});
105+

‎packages/cli/src/utils/internal-ceremony-database-channel.ts‎

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
/**
44
* The CLI's INTERNAL parent-to-child channel for the database `os start`'s
5-
* ownership refusal must name (#31488).
5+
* and `os dev`'s ownership refusal must name (#31488, #31547).
66
*
77
* `os start` resolves its database on the shared ladder
88
* (`resolveProjectDatabaseUrl`, `@objectstack/runtime`) with two inputs no
@@ -20,15 +20,27 @@
2020
* not listed in `content/docs/deployment/environment-variables.mdx`. The
2121
* parent OWNS the variable in the child env: set when there is a database to
2222
* name, deleted otherwise, so an inherited copy never speaks for this run.
23+
*
24+
* `os dev` writes the same channel from its own resolution (#31547): its `-d`
25+
* flag (or `--fresh`'s throwaway file) is just as invisible to the ceremony,
26+
* and its refusal names the ceremony with `--dev`, which resolves the same
27+
* ladder. Beside the URL, both commands carry what their `--database-driver`
28+
* and `--database-auth-token` flags put in the child's environment only
29+
* ({@link writeCeremonyConnectionChannel}): the driver by value, the token
30+
* only as the fact that the flag supplied it.
2331
*/
2432

2533
import type { ResolvedProjectDatabaseUrl } from '@objectstack/runtime';
2634

2735
export const INTERNAL_CEREMONY_DATABASE_URL_ENV = 'OS_INTERNAL_CEREMONY_DATABASE_URL';
36+
/** The `--database-driver` value the parent handed the child (#31547). */
37+
export const INTERNAL_CEREMONY_DATABASE_DRIVER_ENV = 'OS_INTERNAL_CEREMONY_DATABASE_DRIVER';
38+
/** `1` when `--database-auth-token` supplied the child's token (#31547); never the token itself. */
39+
export const INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV = 'OS_INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG';
2840

2941
/**
30-
* The database the refusal must name: `os start`'s resolved URL when its `-d`
31-
* flag chose it, or when the file of a `--home` home is the database (the
42+
* The database the refusal must name: the parent's resolved URL when its `-d`
43+
* flag chose it (`os dev`'s `--fresh` file too), or when the file of a `--home` home is the database (the
3244
* unified default or a legacy file under that home). `undefined` when the
3345
* ceremony's own ladder reaches the same database: `$OS_DATABASE_URL`, the
3446
* project's declared datasource, or the default file of the project's
@@ -55,3 +67,36 @@ export function readCeremonyDatabaseChannel(env: NodeJS.ProcessEnv): string | un
5567
const url = env[INTERNAL_CEREMONY_DATABASE_URL_ENV];
5668
return url ? url : undefined;
5769
}
70+
71+
/**
72+
* Write what the ceremony needs, beside the URL, to open the database the
73+
* child opens (#31547): the `--database-driver` value, and whether
74+
* `--database-auth-token` supplied the token. Each is set exactly when the
75+
* parent put the flag's value in the child's environment
76+
* (`OS_DATABASE_DRIVER` / `OS_DATABASE_AUTH_TOKEN`), and deleted otherwise: a
77+
* driver or token the operator exported is in the shell that runs the
78+
* commands already. The token's value is never written here.
79+
*/
80+
export function writeCeremonyConnectionChannel(
81+
env: NodeJS.ProcessEnv,
82+
flags: { databaseDriver: string | undefined; databaseAuthToken: string | undefined },
83+
): void {
84+
if (flags.databaseDriver) env[INTERNAL_CEREMONY_DATABASE_DRIVER_ENV] = flags.databaseDriver;
85+
else delete env[INTERNAL_CEREMONY_DATABASE_DRIVER_ENV];
86+
if (flags.databaseAuthToken) env[INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV] = '1';
87+
else delete env[INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV];
88+
}
89+
90+
/**
91+
* Read it in the `serve` child, shaped as the guard's options
92+
* (`databaseDriver`, `databaseAuthTokenFromFlag`): only the members the parent set.
93+
*/
94+
export function readCeremonyConnectionChannel(
95+
env: NodeJS.ProcessEnv,
96+
): { databaseDriver?: string; databaseAuthTokenFromFlag?: true } {
97+
const driver = env[INTERNAL_CEREMONY_DATABASE_DRIVER_ENV];
98+
return {
99+
...(driver ? { databaseDriver: driver } : {}),
100+
...(env[INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV] === '1' ? { databaseAuthTokenFromFlag: true as const } : {}),
101+
};
102+
}

0 commit comments

Comments
 (0)