Skip to content

Commit 68cbec1

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15835-doc-frontmatter-prerequisite-refusal
2 parents a46a06b + cee3961 commit 68cbec1

11 files changed

Lines changed: 1242 additions & 14 deletions

.changeset/olive-spiders-refuse.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
**BREAKING** `os create <type> <name>` now refuses a project name that npm refuses, and refuses it before it writes anything.
6+
7+
`os create plugin "My App"` used to exit 0 having written `./plugin-My App/`, carrying a manifest that read `name: "@objectstack/plugin-My App"`. Nothing failed at scaffold time, so the invalid name surfaced later at `npm publish`, in the terminal of whoever ran it next. `os init` has always refused that same input before touching the disk. The rule set is now shared between the two scaffolders rather than restated in one of them, so they answer the same way.
8+
9+
`os create` also refuses a name whose composed scoped package name exceeds npm's 214-character ceiling. `@objectstack/plugin-` spends 20 of those characters before the name begins, so a name that `os init` accepts can still compose to one npm rejects; that check sits next to the composition rather than in the shared rule set.
10+
11+
A scripted invocation that passed an invalid name now exits 1 with the reason on stderr, where it previously exited 0 and produced a project that could not be published.
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) The change narrows what a CLI argument accepts at invocation time. No metadata surface, stored row or spec declaration is touched, so `objectstack migrate meta` has nothing to carry and the ledger has nothing to record. -->
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
The runtime dispatcher door no longer admits a request on a tenancy posture it could not read.
6+
7+
`resolveExecutionContext` reads the effective tenancy posture from the kernel's `tenancy` service, and both posture-conditional API-key refusals (`organization_required`, `organization_membership_ended`) run only when that posture is present. The read used to swallow every failure into "no posture", so a `tenancy` service that was **registered and failed to build** answered exactly like a deployment with no tenancy at all: the wall was skipped, and an API key stamped with an organization its owner had left — or carrying no organization — was admitted with full grants.
8+
9+
The seam now carries the same discrimination the REST door already applies (#13906 decision 1, option A), by the registry's own brand rather than by message text:
10+
11+
- **never registered** — the supported no-tenancy composition. Absorbed as before: no posture, no posture-conditional refusal, nothing changes for single-organization embedders.
12+
- **registered and failed to build** — re-raised as `AuthzStoreUnavailableError`, so the door answers `503 SERVICE_UNAVAILABLE` ("the authorization store could not be read"), which is an existing member of the closed error vocabulary. A posture that could not be read is not a posture that is absent.
13+
14+
Two nets between the resolver and the transport envelope are told the same thing, in the one shape `@objectstack/core` already prescribes for such seams (`rethrowAuthzStoreUnavailable`): the dispatcher's service facade hands the resolver the classified rejection for `tenancy` instead of collapsing it to `undefined`, and the identity step's catch re-raises only the branded outage while every other fault still degrades to an anonymous request. A consequence worth knowing: an authorization-store read failure (`AuthzStoreUnavailableError` from the permission tables) now also reaches this door as 503 instead of being served as an anonymous request.

content/docs/protocol/kernel/index.mdx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,11 @@ that order.
293293

294294
// 1. Identity — resolveExecutionContext() reads the better-auth session (or
295295
// API key), aggregates positions/permission sets/RLS membership, and layers
296-
// locale + timezone on top. It always resolves; anonymous yields
297-
// `{ isSystem: false, positions: [], permissions: [] }`.
296+
// locale + timezone on top. Anonymous yields
297+
// `{ isSystem: false, positions: [], permissions: [] }`. It rejects with
298+
// AuthzStoreUnavailableError (503) only when an authorization input exists
299+
// and could not be read — a failed permission-store read, or a tenancy
300+
// service that is registered and failed to build.
298301
const context = await resolveExecutionContext({ getService, getQl, request });
299302
// → ExecutionContext { userId, tenantId, locale, timezone, positions,
300303
// permissions, isSystem, ... }

packages/cli/src/commands/create.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,11 @@ import path from 'path';
8282
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
8383
import {
8484
getCliVersion,
85+
NPM_PACKAGE_NAME_MAX_LENGTH,
8586
renderPnpmWorkspaceYaml,
8687
sanitizeNamespace,
8788
SCAFFOLD_PNPM_RANGE,
89+
validateProjectName,
8890
} from './init.js';
8991

9092
/**
@@ -165,6 +167,52 @@ function defineTemplate(t: Omit<CreateTemplate, 'files'>): CreateTemplate {
165167
};
166168
}
167169

170+
/**
171+
* The scoped package name a scaffold is about to write, READ BACK off the
172+
* rendered manifest rather than recomposed here.
173+
*
174+
* Recomposing it would be a second copy of `@objectstack/plugin-${name}` that
175+
* nothing keeps in step with the renderer — the same restatement that let this
176+
* command's emitted name drift away from what `os init` enforces. Reading the
177+
* rendered object measures the string that actually lands on disk, and a
178+
* template added later is covered without being told to declare anything.
179+
*
180+
* `null` when the template emits no `package.json`, or emits one without a
181+
* string `name`: there is then no package name to judge, which is not the same
182+
* as judging one and finding it fine.
183+
*/
184+
export function emittedPackageName(
185+
template: CreateTemplate,
186+
placement: ScaffoldPlacement,
187+
name: string,
188+
): string | null {
189+
const render = template.filesFor(placement)['package.json'];
190+
if (!render) return null;
191+
const manifest = render(name) as { name?: unknown } | null | undefined;
192+
return typeof manifest?.name === 'string' ? manifest.name : null;
193+
}
194+
195+
/**
196+
* The one rule `os create` needs and `os init` cannot.
197+
*
198+
* `init`'s argument IS the package name, so measuring the argument is the same
199+
* measurement. `create` composes its argument into a SCOPED name, and npm's
200+
* 214-character ceiling counts the scope: `@objectstack/plugin-` spends 20 of
201+
* them before the user's first character. A 200-character name is therefore
202+
* legal for `init` (measured: accepted) and illegal for `create` (measured:
203+
* emits a 220-character name npm refuses) — which is why the shared validator
204+
* is shared and this check is not.
205+
*/
206+
export function validateEmittedPackageName(packageName: string): string | null {
207+
const over = packageName.length - NPM_PACKAGE_NAME_MAX_LENGTH;
208+
if (over <= 0) return null;
209+
return (
210+
`The package name this would emit is ${packageName.length} characters; npm's limit is `
211+
+ `${NPM_PACKAGE_NAME_MAX_LENGTH}. Shorten the project name by at least ${over} character`
212+
+ `${over === 1 ? '' : 's'}.`
213+
);
214+
}
215+
168216
function toCamelCase(str: string): string {
169217
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
170218
}
@@ -464,12 +512,39 @@ export default class Create extends Command {
464512
console.log(chalk.dim(`Usage: objectstack create ${args.type} <name>`));
465513
process.exit(1);
466514
}
467-
515+
516+
// ⛔ BEFORE the first write, which is the whole property — a refusal that
517+
// arrives after `mkdirSync` has fixed the message and not the defect.
518+
//
519+
// This command used to validate nothing it emitted, so `os create plugin
520+
// "My App"` exited 0 having written `./plugin-My App/` with a manifest
521+
// reading `name: "@objectstack/plugin-My App"` — a name npm refuses —
522+
// while `os init "My App"` refused the same input and wrote nothing. The
523+
// rule set is `init`'s, imported rather than restated: the two scaffolders
524+
// already share four symbols, and the one they did not share is the one
525+
// they disagreed on.
526+
const nameError = validateProjectName(args.name);
527+
if (nameError) {
528+
console.error(chalk.red(`\n❌ ${nameError}`));
529+
console.log(chalk.dim(` Usage: objectstack create ${args.type} <name>`));
530+
process.exit(1);
531+
}
532+
468533
const template = templates[args.type as keyof typeof templates];
469534
const cwd = process.cwd();
470535
const placement: ScaffoldPlacement = flags['in-repo'] ? 'in-repo' : DEFAULT_PLACEMENT;
471536
const projectDirName = template.dirName(args.name);
472537

538+
// The check `init` cannot need, on the string `init` never composes. Also
539+
// before any write, and read off the rendered manifest so it measures what
540+
// would land rather than a second copy of how it is built.
541+
const willEmit = emittedPackageName(template, placement, args.name);
542+
const packageNameError = willEmit ? validateEmittedPackageName(willEmit) : null;
543+
if (packageNameError) {
544+
console.error(chalk.red(`\n❌ ${packageNameError}`));
545+
process.exit(1);
546+
}
547+
473548
// Refuse `--in-repo` outside a workspace rather than emit the one thing
474549
// this command is no longer allowed to emit: a project that cannot install.
475550
if (placement === 'in-repo' && !fs.existsSync(path.join(cwd, 'pnpm-workspace.yaml'))) {

packages/cli/src/commands/init.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -676,13 +676,34 @@ export function detectPackageManager(env: NodeJS.ProcessEnv = process.env): 'npm
676676
return 'npm';
677677
}
678678

679+
/**
680+
* npm's hard ceiling on a package name, the scope included.
681+
*
682+
* Exported because `os create` does NOT validate the same string this file
683+
* does: it composes its argument into a scoped name
684+
* (`@objectstack/plugin-<name>`) and has to measure the COMPOSED string
685+
* against this limit. Restating the number over there is exactly how the two
686+
* scaffolders came to disagree in the first place.
687+
*/
688+
export const NPM_PACKAGE_NAME_MAX_LENGTH = 214;
689+
679690
/**
680691
* Validate that `name` is a usable npm package name AND a safe directory
681692
* segment. Mirrors the subset of rules used by `npm init`/`create-vite`.
693+
*
694+
* Exported for `os create`, which took none of this and therefore accepted
695+
* names npm refuses — `os create plugin "My App"` wrote `./plugin-My App/`
696+
* carrying `name: "@objectstack/plugin-My App"`, while `os init "My App"`
697+
* refused the same input before touching the disk. The rule set is shared
698+
* rather than copied so a rule added here reaches both scaffolders; the one
699+
* check `create` needs and `init` cannot (the length of the composed scoped
700+
* name) lives next to the composition, in `create.ts`.
682701
*/
683-
function validateProjectName(name: string): string | null {
702+
export function validateProjectName(name: string): string | null {
684703
if (!name) return 'Project name is required';
685-
if (name.length > 214) return 'Project name must be ≤ 214 characters';
704+
if (name.length > NPM_PACKAGE_NAME_MAX_LENGTH) {
705+
return `Project name must be ≤ ${NPM_PACKAGE_NAME_MAX_LENGTH} characters`;
706+
}
686707
if (/[A-Z]/.test(name)) return 'Project name must be lowercase';
687708
if (!/^[a-z0-9][a-z0-9._-]*$/.test(name)) {
688709
return 'Project name must start with a lowercase letter or digit and contain only [a-z0-9._-]';

0 commit comments

Comments
 (0)