Skip to content

Commit 6f23667

Browse files
os-zhuangclaude
andauthored
fix(spec,cli): config.storage was never a stack key — stop reading it, and report undeclared top-level keys (#4167) (#4189)
* fix(spec,cli): config.storage was never a stack key — stop reading it, and report undeclared top-level keys (#4167) `os serve` read `config.storage` and forwarded it to StorageServicePlugin. It could almost never arrive: `ObjectStackDefinitionSchema` does not declare `storage` and is not `.strict()`, so `defineStack` — which every documented authoring path and every compiled artifact goes through — strips the key before serve runs. The one combination that reached the branch (bare-object config on the config-boot path) then carried the `driver`/`root` spelling the plugin does not read either. So one authoring key worked on a single unreachable-in-practice path and vanished silently everywhere else. A host writing `storage: { driver: 's3', … }` believed it had configured S3 and got local disk. - serve no longer reads it. `resolveStorageCapabilityArg` takes only the env root, and the production warning names the two channels that work (OS_STORAGE_*, Setup → Settings) instead of advertising the one that does not. - `lintUnknownAuthoringKeys` now covers TOP-LEVEL stack keys, not just object and field keys. `storage` gets a prescriptive entry naming both channels and why a stack definition is the wrong home for a credential — it would commit it to git and to any published artifact. An ordinary misspelling still gets the edit-distance suggestion (`datasource` → `datasources`), and the rule now runs on a stack with no `objects` at all, which previously exited early. - `os migrate files-to-references` shares the resolver. It built the same dead `{ driver: 'local', root }`, so its adapter used `./storage` while the server writes under `.objectstack/data/uploads` since #4096 — and that command reconciles what records claim against what storage holds, so a disagreeing root reconciled against the wrong tree. `lintUnknownAuthoringKeys(rawStack)` becomes `(rawStack, stackSchema)`. Required rather than optional so a caller that forgets it fails to compile instead of silently losing the check — the exact failure this rule reports. Injected rather than imported because stack.zod.ts imports this module and importing back would close a cycle. Verified end to end: authoring `storage:` through defineStack warns at load ("defineStack: stack.storage: 'storage' is not a declared stack key…"), and `os compile` reports it for configs that skip defineStack. spec 273 files / 7150 tests, cli 89 files / 916 tests, root `pnpm lint` clean. Nothing that worked is being removed: `storage` was never in the schema, is undocumented, and has no consumer in objectstack-ai/cloud (checked directly). Closes #4167 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh * docs(validating-metadata): the undeclared-key rule covers top-level stack keys too (#4167) The one page that documents the #3786 rule described it as object/field only, which #4167 made inaccurate: `lintUnknownAuthoringKeys` now also reports keys `ObjectStackDefinitionSchema` does not declare. - Section title and intro name all three schemas. - The gate table row becomes "Undeclared stack/object/field keys". - Adds the top-level case with both guidance kinds, using `storage` as the worked example — it is the one where the silence is easiest to miss, because the key reads as configuration that took effect, and points at the two channels that do configure storage (OS_STORAGE_* and the Settings UI, which is also where credentials belong — a stack definition is committed to git and compiled into any published artifact). Docs-only; found by acting on the docs-drift advisory rather than waving it through. `pnpm check:doc-authoring` clean. Refs #4167 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh * chore(spec): record STACK_KEY_GUIDANCE in the API-surface snapshot (#4167) `check:api-surface` failed on the #4167 commit: exporting `STACK_KEY_GUIDANCE` adds to `@objectstack/spec`'s public surface, and the generated snapshot is the gate's baseline. ./data + STACK_KEY_GUIDANCE (const) @objectstack/spec public API changed: 0 breaking (removed/narrowed), 1 added. Regenerated with the command the gate names. The diff is one line — the export is additive, alongside the FIELD_KEY_GUIDANCE / OBJECT_KEY_GUIDANCE entries it sits with, and nothing was removed or narrowed. Also ran the rest of the gates CI runs, so this does not take another round: doc-authoring, role-word, org-identifier, authz-resolver, route-envelope, error-code-casing, wildcard-fallthrough, release-notes, node-version, plus the spec package's authorable-surface, spec-changes, upgrade-guide, liveness and docs checks — all green. Refs #4167 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh * refactor(spec): rebuild the top-level stack-key lint on the #4178 walker #4178 landed while this was in review and refactored the unknown-authoring-key rule from a two-surface check (object/field) into a walker that derives its coverage from `PLURAL_TO_SINGULAR` and reads each schema's own unknown-key posture. This rebases the #4167 contribution onto that architecture instead of carrying the version it replaced. What changes vs. the pre-merge shape: - `lintUnknownAuthoringKeys` keeps its single-argument signature. The top-level pass is a separate export, `lintUnknownStackKeys(rawStack, stackSchema)`. - It reuses the walker's own `keyPosture`, so it lints only while the stack schema STRIPS unknown keys and goes quiet if that schema is ever made strict — the lint can never become a second, disagreeing voice next to the parse. - `STACK_KEY_GUIDANCE` stays in `data/authoring-key-lint.ts` beside the other two curated tables, held to the same non-rotting discipline. The two passes are complementary, not redundant: the walker iterates metadata COLLECTIONS, so a stack whose only mistake is at the envelope level — no objects, no pages, nothing to iterate — walks clean and reports nothing. A test pins exactly that. Verified end to end after the rebuild: `defineStack` warns at config-load time, and `os validate` / `os build` report `stack.storage` with its prescription plus `stack.datasource` with its edit-distance suggestion, alongside the field-level findings the walker already produced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh * docs(cli): name the right lint export in the serve storage doc-comment The top-level coverage moved to `lintUnknownStackKeys` when this was rebuilt on #4178's walker; the comment still pointed at `lintUnknownAuthoringKeys`, which no longer does that job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh * fix(spec): the top-level lint must not call a working `onEnable` dropped Caught by running the new lint against our own shipped examples before merging: `examples/app-todo` and `examples/app-showcase` both got ⚠ stack.onEnable: 'onEnable' is not a declared stack key, so its value is dropped at load. which is false. `onEnable` is a function, so `ObjectStackDefinitionSchema` cannot declare it and `dist/objectstack.json` cannot carry it — but `AppPlugin` reads it straight off the authored bundle and calls it at `start()` (`app-plugin.ts`), and the artifact-boot path grafts it back (#4095). It is the documented place to register action handlers. "Not declared" and "dropped at load" are different claims, and this is the one surface where they come apart. New `STACK_RUNTIME_MEMBERS` names the authored top-level members the runtime honours off the bundle; `lintUnknownStackKeys` treats them as declared. The CLI's `GRAFTABLE_RUNTIME_MEMBERS` is now DERIVED from it instead of restating it — two hand-written copies could disagree, and the disagreement would be silent in exactly the direction this lint family exists to catch. `onDisable` is deliberately excluded from the exemption: it is declared in the protocol but no kernel, runtime or service calls it, so a value written there really does go nowhere. A test pins both halves of that distinction. Re-harvested after the fix: all three shipped examples validate with zero undeclared-key warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e59786e commit 6f23667

14 files changed

Lines changed: 395 additions & 66 deletions
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/cli': patch
4+
---
5+
6+
**`config.storage` is not a stack key, and an undeclared top-level key now says
7+
so instead of vanishing (#4167).**
8+
9+
`os serve` read `config.storage` and forwarded it to `StorageServicePlugin`.
10+
It could almost never arrive: `ObjectStackDefinitionSchema` does not declare
11+
`storage`, and is not `.strict()`, so `defineStack` — which every documented
12+
authoring path and every compiled artifact goes through — strips the key before
13+
`serve` runs. The one combination that reached the branch (a bare-object config
14+
on the config-boot path) then carried the `driver`/`root` spelling the plugin
15+
does not read either, so it did nothing there too.
16+
17+
The result was one authoring key that worked on a single unreachable-in-practice
18+
path and disappeared silently everywhere else. A host writing
19+
`storage: { driver: 's3', … }` believed it had configured S3 and got local disk.
20+
21+
- **`serve` no longer reads it.** `resolveStorageCapabilityArg` takes only the
22+
env root; the production warning stops naming `config.storage` and names the
23+
two channels that work — `OS_STORAGE_*` and Setup → Settings, the latter being
24+
the one with proper credential handling.
25+
- **The undeclared-key lint now covers the stack's own top-level keys.** New
26+
`lintUnknownStackKeys(rawStack, stackSchema)`, wired into `defineStack`,
27+
`os validate` and `os compile` beside the existing walker. `storage` gets a
28+
prescriptive entry naming both channels and why a stack definition is the
29+
wrong home for a credential — it would commit it to git and to any published
30+
artifact. An ordinary misspelling still gets the edit-distance suggestion
31+
(`datasource``datasources`).
32+
- **`os migrate files-to-references` shares the resolver.** It built
33+
`{ driver: 'local', root }` — the same dead keys — so its adapter used
34+
`./storage` while the server writes under `.objectstack/data/uploads` since
35+
#4096. That command reconciles what records claim against what storage holds,
36+
so a disagreeing root reconciled against the wrong tree.
37+
38+
**`onEnable` is exempt, and the exemption has one owner.** `onEnable` is a
39+
function, so `ObjectStackDefinitionSchema` cannot declare it and
40+
`dist/objectstack.json` cannot carry it — but it is not lost: `AppPlugin` calls
41+
it off the authored bundle, and the artifact-boot path grafts it back (#4095).
42+
"Not declared" and "dropped at load" are different claims, and this is the
43+
surface where they come apart. New `STACK_RUNTIME_MEMBERS` in `@objectstack/spec`
44+
names the members the runtime honours off the bundle; the lint treats them as
45+
declared, and the CLI's `GRAFTABLE_RUNTIME_MEMBERS` is now **derived** from it
46+
rather than restating it, so the list that decides what gets grafted and the
47+
list that decides what the lint stays quiet about cannot drift. `onDisable` is
48+
deliberately not on it — nothing calls it, so a value written there really does
49+
go nowhere and the lint should say so.
50+
51+
Additive: `lintUnknownAuthoringKeys` keeps its signature. The new pass is a
52+
separate export rather than a fold into that walker for two reasons. The walker
53+
iterates metadata COLLECTIONS, so a stack whose only mistake is at the envelope
54+
level — no objects, no pages, nothing to iterate — walks clean; and the stack
55+
schema has to be INJECTED, because `stack.zod.ts` imports the lint module and
56+
importing back would close a cycle. A separate export keeps that requirement
57+
visible: a call site either asks for the coverage or does not, and its absence
58+
shows up in a diff. An optional parameter would be the same silent-loss shape
59+
this rule family exists to report. It follows the walker's posture rule — only a
60+
schema that STRIPS unknown keys is linted, so if the stack schema ever graduates
61+
to `.strict()` the parse takes over and this goes quiet.
62+
63+
Verified end to end: authoring `storage:` through `defineStack` warns at load,
64+
and `os compile` reports it for configs that skip `defineStack`.
65+
66+
Nothing is being taken away that worked. `storage` was never in the schema, is
67+
not documented anywhere, and has no consumer in `objectstack-ai/cloud` (checked).
68+
Whether the platform should eventually grow a real in-stack storage declaration
69+
is a separate question — if so it should follow `datasources`, which solves
70+
credentials by referencing `sys_secret` rather than inlining them, and that
71+
deserves an ADR rather than a resurrected undeclared key.

content/docs/deployment/validating-metadata.mdx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,26 @@ the parse itself rejects loudly, with the schema's own guidance. Because the
248248
lint reads each schema's real unknown-key posture, it can never disagree with
249249
the parse.
250250

251+
It also covers the stack's **own top-level keys** — the envelope those
252+
collections sit in, and the level where the silence is hardest to spot, because
253+
an undeclared key there reads as configuration that took effect rather than as
254+
a typo:
255+
256+
```ts
257+
export default defineStack({
258+
storage: { adapter: 's3', s3: { bucket: 'app-files' } },
259+
// ↑ not a stack key → dropped at load; the app keeps writing to local disk
260+
objects: [...],
261+
});
262+
```
263+
264+
```
265+
stack.storage: 'storage' is not a declared stack key, so its value is dropped at
266+
load — the file-storage backend is a deployment concern, not an application
267+
declaration. Configure it with the OS_STORAGE_* environment variables, or
268+
per-deployment in Setup → Settings → Storage.
269+
```
270+
251271
This is **advisory** — the stack still loads. Strict rejection is where these
252272
schemas are headed (ADR-0049 enforce-or-remove), but these are the protocol's
253273
most-authored surfaces, so the tightening is scheduled on what this check finds
@@ -273,7 +293,7 @@ time, so an author sees them without running the CLI at all.
273293
| View references — form targets, view-key collisions (#2554) |||
274294
| Flow authoring anti-patterns (#1874) |||
275295
| Liveness author-warnings |||
276-
| Undeclared authoring keys, every metadata collection (#3786) |||
296+
| Undeclared authoring keysevery metadata collection (#3786) and the stack's own top-level keys (#4167) |||
277297
| Emits `dist/objectstack.json` |||
278298

279299
So `os validate` is the fast inner-loop check (no artifact); `os build` is what

packages/cli/src/commands/compile.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ObjectStackDefinitionSchema,
1010
normalizeStackInput,
1111
lintUnknownAuthoringKeys,
12+
lintUnknownStackKeys,
1213
formatUnknownAuthoringKey,
1314
type ConversionNotice,
1415
} from '@objectstack/spec';
@@ -263,7 +264,10 @@ export default class Compile extends Command {
263264
// authored through it; this covers the ones that skip it (a plain
264265
// object default-export, `strict: false`) and would otherwise emit an
265266
// artifact with the key quietly gone. Advisory, never fatal.
266-
const unknownKeyFindings = lintUnknownAuthoringKeys(normalized as Record<string, unknown>);
267+
const unknownKeyFindings = [
268+
...lintUnknownStackKeys(normalized as Record<string, unknown>, ObjectStackDefinitionSchema),
269+
...lintUnknownAuthoringKeys(normalized as Record<string, unknown>),
270+
];
267271
if (unknownKeyFindings.length > 0 && !flags.json) {
268272
printWarning(`Undeclared authoring keys (${unknownKeyFindings.length}) — dropped at load (#3786)`);
269273
for (const f of unknownKeyFindings.slice(0, 50)) {

packages/cli/src/commands/migrate/files-to-references.ts

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
import { bootSchemaStack } from '../../utils/schema-migrate.js';
1818
import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
1919
import { describeOccupancy } from '../../utils/sqlite-occupancy.js';
20-
import { loadConfig } from '../../utils/config.js';
20+
import { resolveStorageCapabilityArg } from '../serve.js';
2121

2222
async function confirm(question: string): Promise<boolean> {
2323
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
@@ -36,8 +36,15 @@ async function confirm(question: string): Promise<boolean> {
3636
* Settings first: the storage plugin re-resolves its adapter from persisted
3737
* settings when a settings service is present, which is how an S3-configured
3838
* deployment's backfill uploads land in S3 rather than on this machine.
39-
* Storage config mirrors `os serve`'s resolution (config.storage, else the
40-
* local default) so the CLI materialises bytes exactly where the server would.
39+
* Storage config goes through the SAME resolver `os serve` uses
40+
* (`resolveStorageCapabilityArg`), so the CLI materialises bytes exactly where
41+
* the server would. It did not, and that mattered here more than anywhere: this
42+
* command reconciles what records claim against what storage actually holds, so
43+
* a root that disagrees with the server's reconciles against the wrong tree.
44+
* It built `{ driver: 'local', root }` — keys `StorageServicePluginOptions` does
45+
* not declare — so the adapter silently used the plugin's own `./storage`
46+
* default while the server (since framework#4096) writes under
47+
* `.objectstack/data/uploads`.
4148
*/
4249
async function buildDataMigrationPlugins(): Promise<unknown[]> {
4350
const plugins: unknown[] = [];
@@ -48,19 +55,8 @@ async function buildDataMigrationPlugins(): Promise<unknown[]> {
4855
// optional — without it, constructor/env-driven storage config still applies
4956
}
5057
const { StorageServicePlugin } = await import('@objectstack/service-storage');
51-
let arg: Record<string, unknown> | undefined;
52-
try {
53-
const { config } = await loadConfig();
54-
const cfgStorage = (config as any)?.storage;
55-
if (cfgStorage && (cfgStorage.driver || cfgStorage.adapter)) arg = cfgStorage;
56-
} catch {
57-
// artifact-only project (no objectstack.config.ts) — use the default below
58-
}
59-
if (arg === undefined) {
60-
const root = process.env.OS_STORAGE_ROOT || '.objectstack/data/uploads';
61-
arg = { driver: 'local', root };
62-
}
63-
plugins.push(new StorageServicePlugin({ ...(arg as any), registerRoutes: false }));
58+
const { options } = resolveStorageCapabilityArg(process.env.OS_STORAGE_ROOT);
59+
plugins.push(new StorageServicePlugin({ ...options, registerRoutes: false }));
6460
return plugins;
6561
}
6662

packages/cli/src/commands/serve-storage-capability.test.ts

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616
* option SHAPE, because a shape mismatch is exactly the failure a passing type
1717
* check does not catch when the receiving interface has no index signature and
1818
* the value is built as a plain object literal.
19+
*
20+
* framework#4167 then removed the `config.storage` branch this helper also had.
21+
* `storage` was never a declared stack key, so `defineStack` — which every
22+
* documented authoring path and every compiled artifact goes through — stripped
23+
* it long before `serve` ran. Reading it here made one authoring key work on the
24+
* single path that skips `defineStack` and vanish on all the others. Authors who
25+
* write it are now told, by `lintUnknownAuthoringKeys`.
1926
*/
2027

2128
import { describe, it, expect } from 'vitest';
@@ -24,7 +31,7 @@ import { resolveStorageCapabilityArg } from './serve.js';
2431
describe('resolveStorageCapabilityArg', () => {
2532
it('builds options StorageServicePlugin actually reads', () => {
2633
// `adapter` + `local.rootDir` — NOT `driver` + `root`.
27-
expect(resolveStorageCapabilityArg(undefined).options).toEqual({
34+
expect(resolveStorageCapabilityArg().options).toEqual({
2835
adapter: 'local',
2936
local: { rootDir: '.objectstack/data/uploads' },
3037
});
@@ -33,49 +40,40 @@ describe('resolveStorageCapabilityArg', () => {
3340
it('never emits the keys the plugin ignores', () => {
3441
// The regression guard proper: `{driver, root}` type-checks fine as an
3542
// argument and does nothing at runtime.
36-
const { options } = resolveStorageCapabilityArg(undefined);
43+
const { options } = resolveStorageCapabilityArg();
3744
expect(options).not.toHaveProperty('driver');
3845
expect(options).not.toHaveProperty('root');
3946
});
4047

4148
it('honours OS_STORAGE_ROOT, which the old shape discarded', () => {
42-
const { options, localRoot } = resolveStorageCapabilityArg(undefined, '/srv/uploads');
49+
const { options, localRoot } = resolveStorageCapabilityArg('/srv/uploads');
4350
expect(options).toEqual({ adapter: 'local', local: { rootDir: '/srv/uploads' } });
4451
expect(localRoot).toBe('/srv/uploads');
4552
});
4653

4754
it('ignores a blank or whitespace-only env root', () => {
4855
for (const blank of ['', ' ']) {
49-
expect(resolveStorageCapabilityArg(undefined, blank).options).toEqual({
56+
expect(resolveStorageCapabilityArg(blank).options).toEqual({
5057
adapter: 'local',
5158
local: { rootDir: '.objectstack/data/uploads' },
5259
});
5360
}
5461
});
5562

56-
it('reports the local root so only the fallback triggers the production warning', () => {
57-
// A host that configured its own backend must not be told it is on local disk.
58-
expect(resolveStorageCapabilityArg(undefined).localRoot).toBe('.objectstack/data/uploads');
59-
expect(resolveStorageCapabilityArg({ adapter: 's3', s3: { bucket: 'b', region: 'r' } }).localRoot)
60-
.toBeUndefined();
63+
it('reports the local root the production warning names', () => {
64+
// The warning quotes this, so it has to be the directory actually in use —
65+
// it previously named a root the adapter was not using.
66+
expect(resolveStorageCapabilityArg().localRoot).toBe('.objectstack/data/uploads');
67+
expect(resolveStorageCapabilityArg('/srv/x').localRoot).toBe('/srv/x');
6168
});
6269

63-
it('forwards a host-configured storage block verbatim', () => {
64-
const cfg = { adapter: 's3', s3: { bucket: 'b', region: 'r' } };
65-
expect(resolveStorageCapabilityArg(cfg).options).toBe(cfg);
66-
// The `driver` dialect is still forwarded untouched — the plugin does not
67-
// read it either, but rewriting it here would fossilize the wrong contract
68-
// rather than fix it. Tracked separately.
69-
const legacy = { driver: 's3', bucket: 'b' };
70-
expect(resolveStorageCapabilityArg(legacy).options).toBe(legacy);
70+
it('does not read config.storage at all — it was never a stack key (#4167)', () => {
71+
// `ObjectStackDefinitionSchema` does not declare `storage`, and is not
72+
// `.strict()`, so `defineStack` strips it before serve could see it. Reading
73+
// it here made the same authoring key work on the one path that skips
74+
// `defineStack` and vanish on every documented one. The signature no longer
75+
// accepts it; `lintUnknownAuthoringKeys` now tells the author instead.
76+
expect(resolveStorageCapabilityArg.length).toBe(1);
7177
});
7278

73-
it('falls back when the block names no backend at all', () => {
74-
// `config.storage = { presignedTtl: 60 }` configures no backend, so the
75-
// local default still applies rather than being replaced by a partial block.
76-
expect(resolveStorageCapabilityArg({ presignedTtl: 60 }).options).toEqual({
77-
adapter: 'local',
78-
local: { rootDir: '.objectstack/data/uploads' },
79-
});
80-
});
8179
});

packages/cli/src/commands/serve.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2184,14 +2184,13 @@ export default class Serve extends Command {
21842184
// In production mode we emit a single loud warning so the
21852185
// operator knows to point storage at S3 / GCS / Azure before
21862186
// shipping (data on a single pod is volatile / non-replicated).
2187-
const storageArg = resolveStorageCapabilityArg(
2188-
(config as any).storage,
2189-
process.env.OS_STORAGE_ROOT,
2190-
);
2187+
const storageArg = resolveStorageCapabilityArg(process.env.OS_STORAGE_ROOT);
21912188
arg = storageArg.options;
21922189
if (storageArg.localRoot && !isDev) {
2190+
// Names only the channels that actually work — `config.storage`
2191+
// was in this sentence and was never read (framework#4167).
21932192
console.warn(chalk.yellow(
2194-
` ⚠ StorageServicePlugin using local driver (${storageArg.localRoot}) — switch to S3/GCS/Azure for production (set config.storage or OS_STORAGE_*).`,
2193+
` ⚠ StorageServicePlugin using local driver (${storageArg.localRoot}) — switch to S3/GCS/Azure for production (set OS_STORAGE_* or configure storage in Setup → Settings).`,
21952194
));
21962195
}
21972196
}
@@ -2665,20 +2664,23 @@ export interface StorageCapabilityArg {
26652664
* default IS `./.objectstack/data/uploads`), which swapped the adapter and
26662665
* warned about stranded files — on every boot of a healthy server.
26672666
*
2668-
* A caller-supplied `config.storage` is still forwarded verbatim, including the
2669-
* `driver`/`root` dialect, which the plugin does not read either. That is the
2670-
* same mismatch one layer up and is tracked separately: correcting it means
2671-
* deciding whether the plugin accepts that dialect or the config schema is
2672-
* wrong, and a lenient alias here would fossilize the wrong contract
2673-
* (AGENTS.md Prime Directive #12).
2667+
* `config.storage` is deliberately NOT read (framework#4167). It was never a
2668+
* stack key: `ObjectStackDefinitionSchema` does not declare it, and the schema
2669+
* is not `.strict()`, so `defineStack` — which every documented authoring path
2670+
* and every compiled artifact goes through — strips it before `serve` could
2671+
* ever see it. It arrived here only from a bare-object config on the
2672+
* config-boot path, i.e. one unreachable-in-practice combination, where it then
2673+
* ALSO carried the `driver`/`root` spelling the plugin does not read. Honouring
2674+
* it on that one path meant the same authoring key worked in one place and
2675+
* vanished in every other, which is worse than not having it.
2676+
*
2677+
* The storage backend is a deployment concern with two real channels: the
2678+
* `OS_STORAGE_*` env vars (below) and the `storage` settings namespace, which
2679+
* is also the one with proper credential handling. Authors who write `storage:`
2680+
* anyway now get told so — `lintUnknownStackKeys` reports undeclared top-level
2681+
* keys, and `STACK_KEY_GUIDANCE` names both channels.
26742682
*/
2675-
export function resolveStorageCapabilityArg(
2676-
cfgStorage: any,
2677-
envRoot?: string,
2678-
): StorageCapabilityArg {
2679-
if (cfgStorage && (cfgStorage.driver || cfgStorage.adapter)) {
2680-
return { options: cfgStorage };
2681-
}
2683+
export function resolveStorageCapabilityArg(envRoot?: string): StorageCapabilityArg {
26822684
const rootDir = envRoot?.trim() || '.objectstack/data/uploads';
26832685
return { options: { adapter: 'local', local: { rootDir } }, localRoot: rootDir };
26842686
}

packages/cli/src/commands/validate.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
ObjectStackDefinitionSchema,
1111
normalizeStackInput,
1212
lintUnknownAuthoringKeys,
13+
lintUnknownStackKeys,
1314
formatUnknownAuthoringKey,
1415
type ConversionNotice,
1516
} from '@objectstack/spec';
@@ -97,8 +98,10 @@ export default class Validate extends Command {
9798
// key the author actually wrote. Computed here rather than down in the
9899
// warnings section so the `--json` path reports it too — the
99100
// "computed, then discarded" shape this file already had to fix once.
100-
const unknownKeyWarnings = lintUnknownAuthoringKeys(normalized as Record<string, unknown>)
101-
.map(formatUnknownAuthoringKey);
101+
const unknownKeyWarnings = [
102+
...lintUnknownStackKeys(normalized as Record<string, unknown>, ObjectStackDefinitionSchema),
103+
...lintUnknownAuthoringKeys(normalized as Record<string, unknown>),
104+
].map(formatUnknownAuthoringKey);
102105
const result = ObjectStackDefinitionSchema.safeParse(normalized);
103106

104107
if (!result.success) {

0 commit comments

Comments
 (0)