diff --git a/.changeset/19387-package-registry-mount.md b/.changeset/19387-package-registry-mount.md new file mode 100644 index 00000000000..773433fc1ee --- /dev/null +++ b/.changeset/19387-package-registry-mount.md @@ -0,0 +1,16 @@ +--- +'@objectstack/cli': patch +'@objectstack/metadata-protocol': patch +--- + +fix(cli): `objectstack serve` mounts the always-on `package-registry` capability, so a package created through the API survives a restart on a stock boot (#19387) + +Clause-②: no + +`package-registry` has been on the always-on slate (`PLATFORM_ALWAYS_ON_CAPABILITIES`) since the `marketplace` / `package-registry` split, and `serve` appended it to every app's `requires`. But `Serve.CAPABILITY_PROVIDERS` did not key it, and the resolver's no-provider branch says nothing about a token the app did not declare itself. So an app that did not declare `requires: ['marketplace']` got no `package` service. `POST /api/v1/packages` answered `201`, printed `no 'package' service — '…' registered in-memory only (will not survive a restart)`, and `GET /api/v1/packages/:id` answered `404` after a restart. + +- **`package-registry` now mounts `PackageServicePlugin`** from `@objectstack/service-package`, the provider the spec's `PLATFORM_CAPABILITY_PROVIDERS` row declares for it. A stock boot creates `sys_packages` and replays it at start, so installs and manifest edits made through the API persist. +- **Apps that declare `marketplace` boot as before, with one `PackageServicePlugin`.** `marketplace` resolves to the same provider. The capability resolver now remembers the providers it has mounted itself, so the always-on token does not mount a second copy. Without that change, a declarer's boot would print `Plugin superseded: 'package-service'`. +- **A stock database gains one table, `sys_packages`.** `PackageServicePlugin` creates it with raw DDL, as it already did for `marketplace` declarers. On the in-memory driver (`memory://`), which has no raw SQL, the boot now logs that the DDL was not run and that package hydration was skipped. Packages there last only as long as the process, as before. +- `--preset minimal` still opts out of the whole slate. `protocol.installPackage` keeps its in-memory-only branch as the documented degraded path for hosts that mount no provider. +- **`@objectstack/metadata-protocol`: the `installPackage` docblock no longer says the runtime half is missing.** It used to say that a stock boot still took the in-memory-only branch. It now says that `objectstack serve` mounts `PackageServicePlugin` for `package-registry`, so a stock boot persists, and that the in-memory-only branch is for hosts that mount no provider. The docblock ships in `dist`. No behaviour changes. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 0b56e640195..6b1c0140a0c 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1960,6 +1960,21 @@ export default class Serve extends Command { export: 'PackageServicePlugin', identities: ['package-service', 'PackageServicePlugin'], }, + // The always-on persistence half of the `marketplace` / `package-registry` + // split (#17676 ruling A' items 1-2): `sys_packages` and its boot + // hydration, so `protocol.installPackage` / `updatePackage` find the + // `package` service on a stock boot. Keyed at the provider the spec's + // PLATFORM_CAPABILITY_PROVIDERS row declares for this token — the SAME + // package and plugin as `marketplace` above, because that is what the spec + // map says today. Repointing `marketplace` at the browse surface starts at + // that spec row, and this table follows it; until then an app declaring + // `marketplace` gets ONE PackageServicePlugin, not two — see + // `resolverMounted` in the capability resolver. + 'package-registry': { + pkg: '@objectstack/service-package', + export: 'PackageServicePlugin', + identities: ['package-service', 'PackageServicePlugin'], + }, email: { pkg: '@objectstack/plugin-email', export: 'EmailServicePlugin', @@ -4565,11 +4580,22 @@ export default class Serve extends Command { // the static registry + its token in the spec vocabulary (#3265). const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS; + // Providers THIS resolver has already mounted, by instance. The app's + // own `plugins[]` alone stopped being the whole answer once two tokens + // named one provider: `marketplace` and `package-registry` both resolve + // to PackageServicePlugin, so an app declaring `marketplace` would have + // the always-on `package-registry` mount a second instance, which + // `kernel.use` answers by name with a `Plugin superseded` warn (#19387, + // measured). Pushed only after a successful `kernel.use`, so a provider + // that failed to load under one token is still attempted — with that + // token's own required/best-effort semantics — under the next. + const resolverMounted: unknown[] = []; + // Exact identity comparison, NOT substring containment — a consumer named // after the capability it consumes must never be mistaken for its // provider (#7652). See Serve.providesCapability. const hasPluginMatching = (identities: readonly string[]) => - Serve.providesCapability(plugins, identities); + Serve.providesCapability(plugins, identities) || Serve.providesCapability(resolverMounted, identities); for (const cap of requires) { const spec = CAPABILITY_PROVIDERS[cap]; @@ -4582,6 +4608,14 @@ export default class Serve extends Command { // declared token is a typo that was previously ignored SILENTLY // (#3265) — warn loudly. Warn-first: intended to become a hard error // once the vocabulary proves complete (Prime Directive #12). + // + // A force-appended ALWAYS_ON token must never land here, because it + // passes both conjuncts below and would mount nothing without a word + // (#19387: `package-registry` did exactly that). That is pinned before + // it ships rather than warned about after: every slate token keys a + // CAPABILITY_PROVIDERS entry or a CAPABILITY_TO_TIER tier + // (`serve-capability-vocabulary.test.ts`), and `@objectstack/spec` and + // this package release in one fixed version group. if (declaredRequires.has(cap) && !PLATFORM_CAPABILITY_TOKENS.includes(cap)) { console.warn(chalk.yellow( ` ⚠ requires: "${cap}" is not a known platform capability — check for a typo. It was ignored.`, @@ -4650,7 +4684,9 @@ export default class Serve extends Command { )); } } - await kernel.use(arg !== undefined ? new Ctor(arg) : new Ctor()); + const provider = arg !== undefined ? new Ctor(arg) : new Ctor(); + await kernel.use(provider); + resolverMounted.push(provider); trackPlugin(spec.export); if (spec.extras) { @@ -4660,7 +4696,9 @@ export default class Serve extends Command { const exMod: any = await import(/* webpackIgnore: true */ ex.pkg); const ExCtor = exMod[ex.export]; if (ExCtor) { - await kernel.use(new ExCtor()); + const extra = new ExCtor(); + await kernel.use(extra); + resolverMounted.push(extra); trackPlugin(ex.export); } } catch { diff --git a/packages/cli/test/serve-capability-identity.test.ts b/packages/cli/test/serve-capability-identity.test.ts index 65d6bd423e4..ccdd30a5d5d 100644 --- a/packages/cli/test/serve-capability-identity.test.ts +++ b/packages/cli/test/serve-capability-identity.test.ts @@ -105,6 +105,10 @@ const EXPECTED_PROVIDER_NAME: Record = { realtime: 'com.objectstack.service.realtime', mcp: 'com.objectstack.mcp', marketplace: 'package-service', + // Same provider as `marketplace` (#19387). The resolver's own-mount dedup + // relies on this: each row's identities contain the name the ONE constructed + // PackageServicePlugin registers, which the drift block below re-derives. + 'package-registry': 'package-service', email: 'com.objectstack.service.email', sms: 'com.objectstack.service.sms', sharing: 'com.objectstack.service.sharing', diff --git a/packages/cli/test/serve-capability-vocabulary.test.ts b/packages/cli/test/serve-capability-vocabulary.test.ts index 48a73884ea0..e5dd0ecb77b 100644 --- a/packages/cli/test/serve-capability-vocabulary.test.ts +++ b/packages/cli/test/serve-capability-vocabulary.test.ts @@ -49,16 +49,8 @@ describe('serve capability registries vs spec vocabulary (#3265)', () => { * `Serve.ALWAYS_ON_CAPABILITIES` is a re-export of the spec slate, so this is * a SURFACE pin rather than a second copy of the spec-side one: it asserts * the split survives the hop the CLI takes, and that hop is what decides - * which tokens land in an app's `requires`. - * - * ⚠️ Measured on `serve`'s resolver at c17ff70f3f and deliberately NOT - * asserted: `Serve.CAPABILITY_PROVIDERS` keys `marketplace` and does not yet - * key `package-registry`, so appending this token mounts nothing under - * `objectstack serve` until the runtime half of the same ruling lands - * (#17676 items 2/3/5, the engine lane). Pinning that ABSENCE here would - * turn the engine lane's own fix red for doing the ruled thing, so the gap - * is recorded in words and the pin states only what must hold either side of - * it. + * which tokens land in an app's `requires`. Whether an appended token then + * MOUNTS anything is the next pin's question. */ it("appends the package-registry persistence to every app, never the catalogue half (#17676 A')", () => { expect(Serve.ALWAYS_ON_CAPABILITIES).toContain('package-registry'); @@ -70,6 +62,41 @@ describe('serve capability registries vs spec vocabulary (#3265)', () => { expect(PLATFORM_CAPABILITY_TOKENS).toContain('package-registry'); expect(PLATFORM_CAPABILITY_TOKENS).toContain('marketplace'); }); + + /** + * #19387 — the absence direction, which this file used to record in words + * only. + * + * `serve` force-appends every slate token to an app's `requires`, and then + * mounts a token through exactly one of two paths: a CAPABILITY_PROVIDERS + * entry (the `requires` resolver) or a CAPABILITY_TO_TIER entry (the + * dedicated tier blocks — the named list whose own docblock says why those + * tokens carry no provider entry). A slate token on NEITHER path passes both + * conjuncts of the resolver's no-provider branch — it was not declared by + * the app, and it is inside the vocabulary — so it mounts nothing and says + * nothing. That is how `package-registry` sat on the slate inert after the + * #17676 A' carve-out landed its spec half. + * + * ⛔ So a slate entry with no mount goes red HERE, on the pull request that + * adds it, rather than being noticed as a missing service after release. + * The spec slate and this package ship in one fixed release group, so a + * green pin covers every published pairing; the runtime branch is not given + * a warning for a case this makes unreachable. + */ + it('every always-on slate token has a serve mount — a provider entry or a tier (#19387)', () => { + const providerTokens = new Set(Object.keys(Serve.CAPABILITY_PROVIDERS)); + const tierTokens = new Set(Object.keys(Serve.CAPABILITY_TO_TIER)); + // Non-vacuity: an empty slate would pass the filter below over nothing. + expect(Serve.ALWAYS_ON_CAPABILITIES.length).toBeGreaterThan(0); + const unmounted = Serve.ALWAYS_ON_CAPABILITIES.filter( + (token) => !providerTokens.has(token) && !tierTokens.has(token), + ); + expect( + unmounted, + 'always-on tokens that `serve` force-appends to every app and then mounts NOTHING for — ' + + 'key each one in Serve.CAPABILITY_PROVIDERS at the provider PLATFORM_CAPABILITY_PROVIDERS declares', + ).toEqual([]); + }); }); // framework#3366 — the installable-provider registry must classify EVERY diff --git a/packages/cli/test/serve-package-registry-always-on.e2e.test.ts b/packages/cli/test/serve-package-registry-always-on.e2e.test.ts new file mode 100644 index 00000000000..000f43c7017 --- /dev/null +++ b/packages/cli/test/serve-package-registry-always-on.e2e.test.ts @@ -0,0 +1,324 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #19387 — the always-on `package-registry` capability must MOUNT something. + * + * `package-registry` is on the spec's always-on slate (#17676 ruling A' item + * 1), so `serve` force-appends it to every app's `requires`. Until this card + * `Serve.CAPABILITY_PROVIDERS` did not key it, the resolver's no-provider + * branch stayed silent for a force-appended vocabulary token, and a stock app + * got no `package` service: `POST /api/v1/packages` answered `201`, logged + * `no 'package' service — … registered in-memory only`, and the package was + * gone after a restart (`GET /api/v1/packages/:id` → `404`). Measured on + * `origin/main` 2c1011b0 with this file's own fixture before the fix. + * + * WHAT THIS FILE ASSERTS, and why it spawns the real command. The mount table + * entry is pinned in-process by `serve-capability-vocabulary.test.ts`; that + * pin stays green on a build where the provider fails to start, or where the + * service it registers is never reached by the install primitive. So the + * first block boots `os serve` twice on ONE database file and asks the + * question the card asks — does an API-created package survive a restart? + * + * The second block is the declarer direction. `marketplace` and + * `package-registry` resolve to the SAME provider (the spec's provider map + * says so today), and the resolver's app-supplied check reads only the app's + * own `plugins[]`. Keying the new token alone therefore made an app that + * declares `marketplace` mount PackageServicePlugin twice — `kernel.use` + * answered with `Plugin superseded: 'package-service'` — which is measured, not + * inferred: that was the boot log with the table entry and without the + * resolver's `resolverMounted` list. The second block pins that the declarer + * boots exactly as it did before this card: one PackageServicePlugin. + * + * ⚠️ What this does NOT prove: #17676 ruling A' item 5 — three probes (Studio's + * writable list, a data read, a published-object read) agreeing across a + * restart. That is #17676's acceptance and stays there; this file reads the + * package door alone. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + CLI, + E2E_SECRET_KEY, + TSX, + childEnv, + portContentionError, + portDriftError, + probeThroughChild, + randomPort, + runServe, +} from './helpers/serve-process.js'; + +/** The banner's tail — every row above it, the plugin list included, has printed. */ +const READY = /Press Ctrl\+C to stop/; + +/** The id the install probe creates and the restart probe reads back. */ +const SURVIVOR_ID = 'com.example.survivor'; + +/** The in-memory-only warning `protocol.installPackage` prints when it finds no `package` service. */ +const IN_MEMORY_ONLY = /no 'package' service/; + +/** + * A fixture app shaped like `examples/app-crm` in the one way that matters: it + * declares neither `marketplace` nor `package-registry`. + */ +function appConfig(id: string, requires: readonly string[] | undefined): string { + return ` +export default { + manifest: { + id: 'com.example.${id}', + namespace: '${id}', + version: '1.0.0', + type: 'app', + name: '${id} probe', + }, +${requires ? ` requires: ${JSON.stringify(requires)},\n` : ''} objects: [{ + name: '${id}_task', + label: 'Task', + sharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' } }, + }], +}; +`; +} + +function fixture(prefix: string, config: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + writeFileSync(join(dir, 'objectstack.config.ts'), config, 'utf8'); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: `${prefix}fixture`, private: true, type: 'module' }, null, 2), + 'utf8', + ); + return dir; +} + +/** + * How many times the ready banner's plugin row names `plugin`. The row is the + * line after `Plugins: N loaded` (`printServerReady` in `src/utils/format.ts`); + * `-1` when the banner has no such row, so a missing banner can never read as + * "listed zero times". + */ +function bannerMentions(output: string, plugin: string): number { + const lines = output.split('\n'); + const at = lines.findIndex((line) => /Plugins: \d+ loaded/.test(line)); + if (at === -1 || at + 1 >= lines.length) return -1; + return lines[at + 1]!.split(',').filter((name) => name.trim() === plugin).length; +} + +interface LiveServe { + child: ChildProcessWithoutNullStreams; + /** Everything the child has printed so far, both streams. */ + output: () => string; +} + +const children: ChildProcessWithoutNullStreams[] = []; +const dirs: string[] = []; + +/** + * Boot `os serve --dev` on `db` and keep it running. `--dev` seeds the admin + * the install probe signs in as; on the second boot the admin already exists + * in the database and the seed leaves it alone. + */ +function bootServe(dir: string, port: string, db: string): Promise { + return new Promise((resolveBoot, rejectBoot) => { + const child = spawn(TSX, [CLI, 'serve', 'objectstack.config.ts', '-p', port, '--dev'], { + cwd: dir, + stdio: ['pipe', 'pipe', 'pipe'], + // `childEnv`, never a bare `...process.env` — see its header (#11267). + env: childEnv({ + NO_COLOR: '1', + OS_DATABASE_URL: db, + OS_LOG_LEVEL: '', + OS_DISABLE_CONSOLE: '1', + OS_SECRET_KEY: E2E_SECRET_KEY, + }), + }) as ChildProcessWithoutNullStreams; + children.push(child); + + const what = `os serve --dev on ${db}`; + let out = ''; + let settled = false; + const settle = (err: Error | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) rejectBoot(err); + else resolveBoot({ child, output: () => out }); + }; + const timer = setTimeout( + () => settle(new Error(`${what} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), + 150_000, + ); + const onData = (d: unknown) => { + out += String(d); + // The child is the authority on the port it bound (#12525). + if (READY.test(out)) settle(portDriftError(out, what, port)); + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.on('exit', (code) => + settle( + portContentionError(out, what, port) + ?? new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`), + ), + ); + }); +} + +async function stop(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((done) => { + const give = setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch { + /* already gone */ + } + done(); + }, 10_000); + child.once('exit', () => { + clearTimeout(give); + done(); + }); + try { + child.kill('SIGTERM'); + } catch { + clearTimeout(give); + done(); + } + }); +} + +/** + * One HTTP exchange against `serve`, attributed to the child if the transport + * fails (#15653). ⛔ No assertion goes inside `request`. + */ +function probe(serve: LiveServe, what: string, request: () => Promise): Promise { + return probeThroughChild( + { + child: serve.child, + transcript: () => `\n--- child output ---\n${serve.output().slice(-4000)}`, + label: 'serve-package-registry-always-on', + what, + }, + request, + ); +} + +async function signIn(serve: LiveServe, base: string): Promise { + const res = await probe(serve, 'the sign-in probe', async () => { + const r = await fetch(`${base}/auth/sign-in/email`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }), + }); + let body: unknown = null; + try { body = await r.json(); } catch { /* non-JSON error body */ } + return { status: r.status, body }; + }); + expect(res.status, 'the --dev admin must be able to sign in').toBe(200); + const token = (res.body as { token?: string } | null)?.token; + expect(token, 'sign-in answered 200 without a bearer token').toBeTruthy(); + return String(token); +} + +afterAll(async () => { + for (const child of children) await stop(child); + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}, 60_000); + +describe('#19387: a stock app gets the package service — an API-created package survives a restart', () => { + let firstBoot = ''; + let installStatus = 0; + let restartRead: { status: number; body: string } = { status: 0, body: '' }; + + beforeAll(async () => { + const dir = fixture('pkg-registry-stock-', appConfig('stockboot', undefined)); + dirs.push(dir); + const db = join(dir, 'probe.db'); + const port = randomPort(); + const base = `http://localhost:${port}/api/v1`; + + const first = await bootServe(dir, port, db); + const token = await signIn(first, base); + installStatus = await probe(first, 'the install probe', async () => { + const r = await fetch(`${base}/packages`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ + manifest: { id: SURVIVOR_ID, name: 'Survivor', version: '1.0.0', type: 'app' }, + }), + }); + await r.text(); + return r.status; + }); + // The install's persistence warning (or its absence) lands on the child's + // stderr during the request, so the transcript is read after it returns. + firstBoot = first.output(); + await stop(first.child); + + const second = await bootServe(dir, port, db); + const token2 = await signIn(second, base); + restartRead = await probe(second, 'the post-restart read probe', async () => { + const r = await fetch(`${base}/packages/${SURVIVOR_ID}`, { + headers: { authorization: `Bearer ${token2}` }, + }); + return { status: r.status, body: await r.text() }; + }); + await stop(second.child); + }, 420_000); + + it('the boot mounted PackageServicePlugin although the app declares neither token', () => { + expect( + bannerMentions(firstBoot, 'PackageServicePlugin'), + `the ready banner's plugin row must list PackageServicePlugin once:\n${firstBoot.slice(-3000)}`, + ).toBe(1); + }); + + it('the install found the package service — no in-memory-only fallback', () => { + expect(installStatus, 'POST /api/v1/packages').toBe(201); + expect( + firstBoot, + 'protocol.installPackage fell back to its in-memory-only branch: the stock boot composed no `package` service', + ).not.toMatch(IN_MEMORY_ONLY); + }); + + it('the package is still there after a restart on the same database', () => { + expect( + restartRead.status, + `GET /api/v1/packages/${SURVIVOR_ID} after a restart — 404 means the install lived in memory only`, + ).toBe(200); + expect(restartRead.body).toContain(SURVIVOR_ID); + }); +}); + +describe('#19387: an app that declares `marketplace` still mounts exactly one PackageServicePlugin', () => { + let output = ''; + + beforeAll(async () => { + const dir = fixture('pkg-registry-declarer-', appConfig('declarer', ['marketplace'])); + dirs.push(dir); + const run = await runServe(dir, ['-p', randomPort()], { + waitFor: READY, + env: { OS_DATABASE_URL: join(dir, 'probe.db') }, + }); + output = run.stdout + run.stderr; + }, 240_000); + + it('the boot reached its ready banner', () => { + expect(output).toMatch(READY); + }); + + it('`marketplace` and the always-on `package-registry` share one provider instance', () => { + expect( + output, + 'the always-on token mounted a SECOND PackageServicePlugin over the declared one — the resolver did not ' + + 'recognise the provider it had itself just mounted', + ).not.toMatch(/Plugin superseded: 'package-service'/); + expect(bannerMentions(output, 'PackageServicePlugin')).toBe(1); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 74ee23265ed..35f178907b5 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -22643,17 +22643,18 @@ export class ObjectStackProtocolImplementation implements * boot hydration that replays it — out under its own always-on token * `package-registry` (`PLATFORM_ALWAYS_ON_CAPABILITIES`, * `packages/spec/src/kernel/platform-capabilities.ts`), leaving - * `marketplace` naming only the optional catalogue / browsing half. ⚠️ The - * runtime half of that split is NOT landed: measured on `origin/main` at - * c334ba0f3a, `Serve.CAPABILITY_PROVIDERS` - * (`packages/cli/src/commands/serve.ts`) keys `marketplace` and does not key - * `package-registry`, so the always-on token is force-appended to every - * app's `requires` and then resolves to no provider — silently, because the - * resolver only warns for tokens outside the vocabulary. ⇒ on a stock - * `objectstack dev` boot of an app that does not itself declare - * `requires: ['marketplace']`, this branch is still the one taken, which is - * the defect #17676 reports. Recorded here rather than worked around: the - * fix belongs to the capability resolver, not to this primitive. + * `marketplace` naming only the optional catalogue / browsing half. The + * runtime half of that split has landed (#19387): `objectstack serve` keys + * `package-registry` in `Serve.CAPABILITY_PROVIDERS` + * (`packages/cli/src/commands/serve.ts`) and mounts `PackageServicePlugin` + * for it, so a stock boot — an app that declares neither token — composes + * the `package` service and takes the `pkgSvc.publish` branch below. The + * in-memory-only branch is reached only on a host that mounts no provider + * (`objectstack serve --preset minimal`, a metadata-only embedding), the + * degraded path described above. ⚠️ Still open: `marketplace` maps to the + * same persistence provider today, and repointing it at the browse surface + * is #17676's remaining half. That repoint does not change which branch + * this primitive takes. * * [#19277] `request.enableOnInstall` is HONOURED here, under the same rule * the HTTP door implements — 「缺省 = 保持,有旗 = 设置」: `true` enables,