diff --git a/README.md b/README.md
index bdfdc23..b7900ff 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,9 @@ pnpm dev
```
Open [http://127.0.0.1:4174/?demo=1](http://127.0.0.1:4174/?demo=1) for the
-sample plan, or remove `?demo=1` to connect an mdbase collection.
+sample plan, or remove `?demo=1` to connect an mdbase collection. Use
+`127.0.0.1`, not the `localhost` alias: the manifest and callback share this
+single development origin.
Useful checks:
@@ -108,8 +110,9 @@ local callback URLs are never deployed.
The application requests full-collection authorization because mdbase saved
views are collection-level resources. Task records are still selected and
-mutated only through `tasknotes.task`. Planner requests only the capabilities
-it uses:
+mutated only through `tasknotes.task`. Planner declares capability contract v2
+with required atomic groups `collection.read`, `records.edit`, `views.manage`,
+and `definitions.manage` for these workflows:
- inspect the collection contract
- apply the shared TaskNotes type pack and declared Base-source include setting
@@ -120,6 +123,14 @@ it uses:
- list and execute saved views
- read, create, and update saved-view sources
+Atomic consent also grants record validation/rename, type reading/creation, and
+saved-view source deletion. Saved-view deletion is not record deletion, and no
+independent operation permission is requested. Record creation/deletion, files,
+offline replicas, and background scheduling remain excluded. See
+[the migration guard and rollout order](docs/capability-v2-migration.md): the
+vendored beta96 v2 candidate SDK is source-bound development evidence, not a published or
+signed release. This v2 draft must not deploy before the qualified v2 writer.
+
The production declaration assumes `https://planner.tasknotes.dev/`. Update the
homepage, icon, and redirect URI together if the deployment origin changes.
diff --git a/docs/capability-v2-migration.md b/docs/capability-v2-migration.md
new file mode 100644
index 0000000..2eec5ce
--- /dev/null
+++ b/docs/capability-v2-migration.md
@@ -0,0 +1,210 @@
+# Capability v2 migration — do not deploy
+
+This is an unpublished SDK + v2 integration draft. **Do not merge into
+auto-deploying main, push, publish, or deploy this branch.** The beta95 SDK
+upgrade does not authorize release of this v2 declaration.
+
+Required rollout order:
+
+1. Deploy beta95 readers only, retaining fresh issuance v1 (`fresh=1`).
+2. Subsequently qualify and deploy the v2 writer, retaining beta95 as rollback.
+3. Only then qualify and release consumers declaring v2, including Planner.
+
+The SDK may be beta95 before step 2; the consumer v2 manifest must not release
+before the writer. Parent-owned LAB and signed staging/release evidence remain
+separate prerequisites. Never substitute workspace packages or patch vendor
+archives to hide an SDK defect.
+
+## Current beta96 v2 candidate provenance
+
+The active SDK pins now use authentic `0.1.0-beta.96` SHA-qualified development
+packs from committed product source
+`56ed32ffde0544ca497e854702d87ef2c175b954`, supplied by the parent at
+`/home/calluma/projects/mdbase-connect/.ops/artifacts/v2-sdk-56ed32ffde05`.
+The parent generated these with `pack-consumer-sdk.mjs` for phase
+`v2-enablement`, supporting capability contracts `[1, 2]`.
+These are **not signed Q artifacts or an npm publication**.
+
+The existing vendor subset is preserved. Every copied archive was checked against
+the supplied manifest's byte length and SHA-512, and its package version was
+checked as `0.1.0-beta.96`. `vendor/mdbase-connect-sdk.json` records the new
+revision, filenames, sizes and SHA-512 values. Dependency and transitive override
+pins are updated together; package-manager installation regenerates the lockfile.
+No archive is patched or relabelled and no package dependency is added.
+
+The beta95 account below is historical, superseded for active pins by this
+candidate. Release remains held: the parent owns rollout ordering, live/native
+acceptance, and repinning immutable final release artifacts before consumer
+publication if required. Existing v1 sessions remain retained; updated consent
+requires explicit reauthorization without silent conversion or fallback.
+
+## Historical beta95 provenance
+
+The previous pins were **beta95-Q**, not enabled-candidate artifacts. Consumer
+v2 enablement is authorized in the coordinated cycle; core issuance and SDK
+generation remain parent-owned. At that stage, the parent needed to supply actual enabled-candidate
+`package:consumer` output and update the connect/protocol/sync archives,
+`vendor/mdbase-connect-sdk.json`, dependency/override pins and `pnpm-lock.yaml`
+together. Record actual source revision, versions, sizes and hashes; do not
+relabel beta95-Q or pin dirty Writer source. Repeat consumer checks against
+those exact artifacts before live acceptance.
+
+`vendor/mdbase-connect-sdk.json` is the connect/protocol/sync subset of product
+`package:consumer` output from clean source
+`408c67bc10f128e0833f0da62cb3efb9d94657d7`, version `0.1.0-beta.95`, supplied
+in `/tmp/beta95-consumer-sdk.fmn7sH`. The parent verified artifact sizes,
+SHA-512 values and versions. These are **SOURCE-BOUND local-build artifacts**,
+not signed-image or published-registry release evidence. The old beta85 archives
+are replaced, dependencies/overrides and the frozen lockfile use beta95 without
+unrelated upgrades. Sync is retained and overridden to the matching artifact;
+this connect package currently depends only on protocol, so Planner does not
+import or install sync merely to claim replica support.
+
+## Inventory and consent
+
+The production generator is `scripts/planner-manifest.mjs`, invoked through
+`pnpm manifest` / `scripts/write-manifest.mjs`. Its previous v1 requirements were:
+
+- `collection.inspect`, `collection.setup.apply`
+- `definitions.update`, `definitions.type-pack.apply`
+- `records.watch`, `records.read`, `records.query`, `records.update`
+- `views.list`, `views.execute`, `views.source.read`, `views.source.create`,
+ `views.source.update`
+
+It now declares `contract_version: 2` with exactly these required atomic groups
+and no optional capabilities:
+
+| Group | Consent meaning |
+| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
+| `collection.read` | Describe, changes, read/query, list/execute views, read view sources, validate records, and read types. |
+| `records.edit` | Update and rename records; no record creation or deletion. |
+| `views.manage` | Create, update, and delete saved-view sources. Whole-group consent intentionally includes saved-view deletion, not record deletion. |
+| `definitions.manage` | Create/update types and assess/apply type packs. |
+
+Compared with the previous operation union, consent additionally covers record
+validation, type reading, record rename, type creation, and saved-view source
+deletion. These are atomic group meanings, not independently requested operation
+permissions. Setup assessment/application is handled by Connect's separate
+setup flow, with the existing provisions unchanged.
+
+No `records.create`, `records.delete`, `offline.replica`, `files.*`, or
+`background.schedule` is requested. Full-collection authorization remains
+necessary for Bases, while the application continues to select and mutate task
+records through `tasknotes.task` only. No app capability aliases or fabricated
+SDK groups were introduced; session state handling remains SDK-owned.
+
+## Preserved boundaries
+
+The exact TaskNotes contracts, type-pack provisions, configuration requirement
+and provision, and production callback are unchanged. Generated paths remain
+`public/.well-known/mdbase-app.json` and `src/generated-mdbase-app.json`.
+Development uses the existing documented `http://127.0.0.1:4174` origin only:
+the invalid cross-origin localhost callback is removed. Runtime uses the declared
+callback and rejects opening a connection from an undeclared origin with an
+explicit URL instruction; origins are not broadened.
+
+`scripts/planner-manifest.mjs` now invokes the installed protocol's strict
+`parseAppManifest`, so normal manifest/build/verify fail on invalid declarations.
+Local HTTP is accepted only in development. No v2-to-v1 compatibility fallback
+is used. SDK session readiness owns consent, exact contract verification and
+setup; the gate requires a user click to review updated access and never
+silently upgrades the prior grant.
+
+## Prior declaration-only evidence (superseded by beta95 checks below)
+
+- `pnpm install --frozen-lockfile`, `pnpm manifest`: passed.
+- `pnpm test`: 9 files, 56 tests passed, including exact group and generated
+ manifest checks.
+- `pnpm typecheck`, `pnpm lint`, `pnpm build`: passed. Build success does not
+ demonstrate SDK runtime compatibility.
+- Both production generated manifests passed the sibling feature Connect
+ `packages/protocol/dist/manifest.js` `validateAppManifest` validator. This was
+ a read-only local import; no Connect edits or production API calls.
+- The unchanged development manifest fails that validator even with
+ `allowLocal: true`: `/redirect_uris/1` uses localhost rather than the homepage's
+ 127.0.0.1 origin. Callback behavior was deliberately preserved, not silently
+ changed to satisfy validation.
+- The pinned beta85 SDK constructor accepts the declaration with explicit
+ in-memory storage, but its real `effectiveCapabilities` evaluator throws
+ `TypeError: APPLICATION_CAPABILITY_DEFINITIONS[capability] is not iterable`
+ for the v2 declaration. This deterministic, no-network probe confirms the
+ release blocker despite passing mocked application tests and build checks.
+
+## Beta95 integration evidence (Node 24.19.0)
+
+- Parent reran frozen install and full `pnpm verify`: formatting, typecheck,
+ lint, all 65 tests, strict production manifest validation and build pass.
+ Lockfile formatting churn was removed; the remaining diff is SDK-related.
+ Parent independently verified all vendored artifact sizes and SHA-512 hashes.
+- Genuine installed SDK construction, manifest loading and v2 capability
+ evaluation pass without network; stale v1 operation sets and denied groups
+ require authorization instead of throwing or silently upgrading.
+- Real SDK application-session tests use fixture transport only: stale
+ registration with sufficient operations still requires explicit consent;
+ denied consent cannot apply setup; declaration mismatch and denied describe
+ require authorization; missing exact contracts block readiness; matching
+ contracts and current setup reach verified ready.
+- UI regressions cover denied consent, explicit reauthorization, stale grants,
+ startup/callback lifecycle and setup review. No timeout or budget is raised.
+
+No browser/LAB acceptance, production smoke API, deployment, commit, or push is
+performed. E2E is demo-only but requires a browser, prohibited for this task;
+parent-owned isolated execution remains required. Parent LAB must verify real
+consent denial/revocation, old-grant reauthorization, contract/type-pack setup,
+Bases full-collection access and contract-scoped task edits with the qualified
+writer and beta95 rollback. Local fixtures are not release acceptance.
+
+## Coordinated consumer verification — 2026-09-09
+
+With Node 24.19.0, the initial `pnpm test` passed 11 files / 65 tests;
+`pnpm typecheck` and `pnpm build` passed, including strict manifest validation
+and static callback verification. After extending repository regressions,
+`pnpm test` passed 11 files / 66 tests and `pnpm typecheck` passed again.
+The added assertions cover contract-scoped query/read/schedule updates with
+revision guards, and saved-view updates preserving unrelated views/options
+using the freshly read source revision. No runtime failure was found and no
+additional group was requested to satisfy a test.
+
+Parent LAB still needs fresh consent and stale v1 reauthorization, denied and
+revoked access without clearing unrelated state, exact-contract/setup review,
+date/dependency/status edits, saved-view list/execute/create/update, authority
+failures and recovery across hosted and connected-computer collections. Tests
+and builds here used fixtures/local artifacts only; no browser, LAB, smoke,
+deployment or publication ran.
+
+## Beta96 candidate integration results — 2026-09-09
+
+- Full `pnpm verify`: passed, including formatting, typecheck, lint,
+ **11 files / 66 tests**, strict manifest parsing and production build.
+- Existing contract-scoped record/schedule and full-collection saved-view CAS
+ regression assertions are preserved.
+
+The installed beta96 SDK checks `/health` for
+`application-authorization-v2-issuance` before fresh v2 authorization. TaskNotes,
+Workouts and Pickle hermetic transport fixtures now model that endpoint; they
+still assert exact signed intent and denial. Workouts additionally verifies a
+v1-only server returns `capability_contract_incompatible` without an authorization
+request or navigation. No production application source, authority declaration,
+retained-session migration, or native route changed in this integration pass.
+
+All checks used Node `v24.19.0` via the requested PATH. Initial cache writes
+failed with `EROFS`; existing package-manager caches were copied into the private
+log directory under `/tmp`, then offline installation succeeded in all four
+worktrees (`pnpm install --no-frozen-lockfile --prod=false --offline --store-dir …`
+or `npm install --include=dev --offline --cache …`). Lockfiles were regenerated
+by package managers, with formatting restored where required. Every unrelated
+package/snapshot lock entry is structurally unchanged from the starting draft.
+No dependencies, audit waivers or timeout increases were introduced.
+
+These are local fixture/unit results, not live browser, daemon, or native
+acceptance. Parent acceptance still needs fresh registration and explicit v1
+reauthorization, retained valid v1 grants, denial/revocation and recovery, exact
+contract/setup and readonly view behavior, typed records and revision guards,
+TaskNotes/Planner saved views, Workouts timers, and actual opt-in notifications
+and opaque Pickle wakeups across the supported providers and native callbacks.
+Parent owns release ordering and immutable final artifact repinning before
+publication if required. Reader, bundled clients and canonical Connect were not
+edited; no agents, browser operations, credentials, live service acceptance,
+commits, pushes, publishing or deployments were used.
+
+Private logs: `/tmp/v2-consumer-sdk-integration-20260909T130351/tasknotes-planner`.
diff --git a/package.json b/package.json
index 9b83674..e2d2369 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,7 @@
"dependencies": {
"@fontsource/atkinson-hyperlegible": "5.3.0",
"@fontsource/azeret-mono": "5.3.0",
- "@mdbase-dev/connect": "file:vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz",
+ "@mdbase-dev/connect": "file:vendor/mdbase-dev-connect-0.1.0-beta.96-56ed32ffde05.tgz",
"@tasknotes/model": "file:vendor/tasknotes-model-0.3.0-rc.11.tgz",
"lucide-react": "1.25.0",
"react": "19.2.8",
@@ -35,6 +35,7 @@
},
"devDependencies": {
"@eslint/js": "10.0.1",
+ "@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz",
"@playwright/test": "1.61.1",
"@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2",
@@ -55,7 +56,8 @@
},
"pnpm": {
"overrides": {
- "@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz"
+ "@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz",
+ "@mdbase-dev/connect-sync": "file:vendor/mdbase-dev-connect-sync-0.1.0-beta.96-56ed32ffde05.tgz"
}
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e0c6137..40758ed 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -5,7 +5,8 @@ settings:
excludeLinksFromLockfile: false
overrides:
- "@mdbase-dev/connect-protocol": file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz
+ "@mdbase-dev/connect-protocol": file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz
+ "@mdbase-dev/connect-sync": file:vendor/mdbase-dev-connect-sync-0.1.0-beta.96-56ed32ffde05.tgz
importers:
.:
@@ -17,8 +18,8 @@ importers:
specifier: 5.3.0
version: 5.3.0
"@mdbase-dev/connect":
- specifier: file:vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz
- version: file:vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz
+ specifier: file:vendor/mdbase-dev-connect-0.1.0-beta.96-56ed32ffde05.tgz
+ version: file:vendor/mdbase-dev-connect-0.1.0-beta.96-56ed32ffde05.tgz
"@tasknotes/model":
specifier: file:vendor/tasknotes-model-0.3.0-rc.11.tgz
version: file:vendor/tasknotes-model-0.3.0-rc.11.tgz
@@ -38,6 +39,9 @@ importers:
"@eslint/js":
specifier: 10.0.1
version: 10.0.1(eslint@10.7.0)
+ "@mdbase-dev/connect-protocol":
+ specifier: file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz
+ version: file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz
"@playwright/test":
specifier: 1.61.1
version: 1.61.1
@@ -478,21 +482,21 @@ packages:
integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==,
}
- "@mdbase-dev/connect-protocol@file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz":
+ "@mdbase-dev/connect-protocol@file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz":
resolution:
{
- integrity: sha512-+lXIWlLhItrHXiVHUfs4DIuo7U0h1Z7GByha9lyAoNDe+TDi9RY0x+koUTTkwKvn7AqjWRVAxfLqpdetiK7Thg==,
- tarball: file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz,
+ integrity: sha512-prz8clP0Vpu8HQ4ucr+ss4am2sLQpFTOjXBSRcaU8/RIdDM7tZurOYHP/y0LdDkViJ9pODfD6R82umpdvMQYhQ==,
+ tarball: file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz,
}
- version: 0.1.0-beta.85
+ version: 0.1.0-beta.96
- "@mdbase-dev/connect@file:vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz":
+ "@mdbase-dev/connect@file:vendor/mdbase-dev-connect-0.1.0-beta.96-56ed32ffde05.tgz":
resolution:
{
- integrity: sha512-4KvCC9UGfAdfOKFFKIrQPFNq5w/14bktuPbGB3EJlKnYsP1V+qVxpyHt9SzpgqSyTaRLpigCTxTixZrT/aW0Yg==,
- tarball: file:vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz,
+ integrity: sha512-7k2TL4IVu4IyWSs+1DuSDrY/Vw0vAHlpgL3Zq9R/Bb1I61TMwTTQ2p/aYx400FT69qQGO1CA6SHs7oHNroHh5w==,
+ tarball: file:vendor/mdbase-dev-connect-0.1.0-beta.96-56ed32ffde05.tgz,
}
- version: 0.1.0-beta.85
+ version: 0.1.0-beta.96
"@napi-rs/wasm-runtime@1.2.3":
resolution:
@@ -2514,14 +2518,14 @@ snapshots:
"@jridgewell/resolve-uri": 3.1.2
"@jridgewell/sourcemap-codec": 1.5.5
- "@mdbase-dev/connect-protocol@file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz":
+ "@mdbase-dev/connect-protocol@file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz":
dependencies:
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
- "@mdbase-dev/connect@file:vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz":
+ "@mdbase-dev/connect@file:vendor/mdbase-dev-connect-0.1.0-beta.96-56ed32ffde05.tgz":
dependencies:
- "@mdbase-dev/connect-protocol": file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz
+ "@mdbase-dev/connect-protocol": file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz
"@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)":
dependencies:
diff --git a/public/.well-known/mdbase-app.json b/public/.well-known/mdbase-app.json
index 7720b56..87b69f4 100644
--- a/public/.well-known/mdbase-app.json
+++ b/public/.well-known/mdbase-app.json
@@ -14,21 +14,12 @@
}
],
"capabilities": {
- "contract_version": 1,
+ "contract_version": 2,
"required": [
- "collection.inspect",
- "collection.setup.apply",
- "definitions.update",
- "definitions.type-pack.apply",
- "records.watch",
- "records.read",
- "records.query",
- "records.update",
- "views.list",
- "views.execute",
- "views.source.read",
- "views.source.create",
- "views.source.update"
+ "collection.read",
+ "records.edit",
+ "views.manage",
+ "definitions.manage"
]
},
"access": "full_collection",
diff --git a/scripts/planner-manifest.mjs b/scripts/planner-manifest.mjs
index f9c7aba..8d974f5 100644
--- a/scripts/planner-manifest.mjs
+++ b/scripts/planner-manifest.mjs
@@ -1,35 +1,26 @@
+import { parseAppManifest } from "@mdbase-dev/connect-protocol/manifest";
+
import { loadCanonicalTaskNotesTypePack } from "./canonical-task-pack.mjs";
export { TASKNOTES_TYPE_PACK_VERSION } from "./canonical-task-pack.mjs";
export const requiredCapabilities = Object.freeze([
- "collection.inspect",
- "collection.setup.apply",
- "definitions.update",
- "definitions.type-pack.apply",
- "records.watch",
- "records.read",
- "records.query",
- "records.update",
- "views.list",
- "views.execute",
- "views.source.read",
- "views.source.create",
- "views.source.update",
+ "collection.read",
+ "records.edit",
+ "views.manage",
+ "definitions.manage",
]);
export async function buildPlannerManifest({ appUrl, development = false }) {
const origin = appUrl.replace(/\/$/, "");
const redirectUris = [`${origin}/auth/mdbase/callback`];
- if (development && origin === "http://127.0.0.1:4174")
- redirectUris.push("http://localhost:4174/auth/mdbase/callback");
const typePack = await loadCanonicalTaskNotesTypePack();
const taskContract = typePack.provides.find(
(contract) => contract.id === "tasknotes.task",
);
if (!taskContract)
throw new Error("TaskNotes pack provides no task contract.");
- return {
+ const manifest = {
manifest_version: 1,
id: "dev.tasknotes.planner",
name: "TaskNotes Planner",
@@ -39,7 +30,7 @@ export async function buildPlannerManifest({ appUrl, development = false }) {
requirements: {
contracts: [taskContract],
capabilities: {
- contract_version: 1,
+ contract_version: 2,
required: [...requiredCapabilities],
},
access: "full_collection",
@@ -64,4 +55,7 @@ export async function buildPlannerManifest({ appUrl, development = false }) {
],
},
};
+ // Validate with the installed protocol, without translating v2 to v1.
+ parseAppManifest(manifest, { allowLocal: development });
+ return manifest;
}
diff --git a/scripts/planner-manifest.test.mjs b/scripts/planner-manifest.test.mjs
index 0b4cf62..acc2908 100644
--- a/scripts/planner-manifest.test.mjs
+++ b/scripts/planner-manifest.test.mjs
@@ -1,3 +1,4 @@
+import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import {
@@ -17,12 +18,13 @@ describe("Planner mdbase manifest", () => {
expect(manifest.requirements).toMatchObject({
access: "full_collection",
capabilities: {
- required: expect.arrayContaining([
- "collection.setup.apply",
- "definitions.update",
- "definitions.type-pack.apply",
- "views.execute",
- ]),
+ contract_version: 2,
+ required: [
+ "collection.read",
+ "records.edit",
+ "views.manage",
+ "definitions.manage",
+ ],
},
});
expect(manifest.provisions.type_packs).toHaveLength(1);
@@ -56,14 +58,36 @@ describe("Planner mdbase manifest", () => {
]);
});
- it("adds both supported loopback callbacks only for development", async () => {
+ it("publishes exactly the generated production declaration", async () => {
+ const manifest = await buildPlannerManifest({
+ appUrl: "https://planner.tasknotes.dev",
+ });
+ expect(manifest.requirements.capabilities).toEqual({
+ contract_version: 2,
+ required: [
+ "collection.read",
+ "records.edit",
+ "views.manage",
+ "definitions.manage",
+ ],
+ });
+ for (const path of [
+ "../public/.well-known/mdbase-app.json",
+ "../src/generated-mdbase-app.json",
+ ]) {
+ expect(
+ JSON.parse(await readFile(new URL(path, import.meta.url), "utf8")),
+ ).toEqual(manifest);
+ }
+ });
+
+ it("uses only the documented development origin for callbacks", async () => {
const manifest = await buildPlannerManifest({
appUrl: "http://127.0.0.1:4174",
development: true,
});
expect(manifest.redirect_uris).toEqual([
"http://127.0.0.1:4174/auth/mdbase/callback",
- "http://localhost:4174/auth/mdbase/callback",
]);
});
});
diff --git a/scripts/planner-sdk-session.test.mjs b/scripts/planner-sdk-session.test.mjs
new file mode 100644
index 0000000..e9c8743
--- /dev/null
+++ b/scripts/planner-sdk-session.test.mjs
@@ -0,0 +1,151 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ MdbaseApplicationSession,
+ MdbaseMemorySelection,
+} from "@mdbase-dev/connect";
+import {
+ capabilityOperationsForContractVersion,
+ APPLICATION_SETUP_OPERATIONS,
+} from "@mdbase-dev/connect-protocol";
+import { buildPlannerManifest } from "./planner-manifest.mjs";
+
+const ok = (value) => ({ ok: true, value, diagnostics: [] });
+const denied = (code) => ({
+ ok: false,
+ problem: { code, message: code },
+ diagnostics: [],
+});
+
+// Only the transport boundary is a fixture: lifecycle, setup checks, exact-contract
+// verification and capability evaluation all execute the installed SDK.
+async function fixture({
+ stale = false,
+ contracts,
+ setupProblem,
+ describeProblem,
+} = {}) {
+ const manifest = await buildPlannerManifest({
+ appUrl: "https://planner.tasknotes.dev",
+ });
+ const operations = [
+ ...new Set([
+ ...manifest.requirements.capabilities.required.flatMap((id) =>
+ capabilityOperationsForContractVersion(2, id),
+ ),
+ ...APPLICATION_SETUP_OPERATIONS,
+ ]),
+ ];
+ const info = {
+ collectionId: "fixture",
+ displayName: "Fixture",
+ operations,
+ scope: { access: "full_collection", contracts: [] },
+ authority: { kind: "connector", durability: "computer" },
+ route: "relay",
+ directAccess: "unavailable",
+ };
+ const connection = {
+ collectionId: info.collectionId,
+ info: () => info,
+ onConnectionChange: () => () => {},
+ authorizationCapabilities: (required) => ({
+ authorized: true,
+ sufficient: required.every((op) => operations.includes(op)),
+ grantedOperations: operations,
+ missingOperations: required.filter((op) => !operations.includes(op)),
+ }),
+ assessCollectionSetup: vi.fn(async () =>
+ setupProblem
+ ? denied(setupProblem)
+ : ok({
+ status: "current",
+ applicable: true,
+ configuration: [],
+ typePacks: [],
+ }),
+ ),
+ applyCollectionSetup: vi.fn(),
+ describe: vi.fn(async () =>
+ describeProblem
+ ? denied(describeProblem)
+ : ok({
+ collectionId: info.collectionId,
+ contracts: contracts ?? manifest.requirements.contracts,
+ }),
+ ),
+ };
+ const transport = {
+ register: async () =>
+ ok({
+ id: "v2-registration",
+ family_identity: `bundle:${manifest.id}`,
+ manifest_digest: "a".repeat(64),
+ }),
+ manifest: async () => ok(manifest),
+ connections: () => [info],
+ connection: () => connection,
+ connectionApplicationId: () =>
+ stale ? "old-v1-registration" : "v2-registration",
+ onConnectionsChange: () => () => {},
+ unavailableReason: () => null,
+ authorize: vi.fn(async () => denied("access_denied")),
+ };
+ const selection = new MdbaseMemorySelection();
+ selection.select(info.collectionId);
+ const session = new MdbaseApplicationSession(transport, {
+ selection,
+ autoSelect: "never",
+ });
+ return { session, connection, transport };
+}
+
+describe("installed beta95 Planner session", () => {
+ it("requires explicit consent for a stale registration even with sufficient operations", async () => {
+ const { session, connection, transport } = await fixture({ stale: true });
+ try {
+ expect((await session.start()).ok).toBe(true);
+ expect(session.getSnapshot().status).toBe("authorization_required");
+ expect(transport.authorize).not.toHaveBeenCalled();
+ expect(connection.assessCollectionSetup).not.toHaveBeenCalled();
+ expect((await session.authorize("selected")).ok).toBe(false);
+ expect(transport.authorize).toHaveBeenCalledTimes(1);
+ expect(transport.authorize.mock.calls[0][0].target).toEqual({
+ kind: "collection",
+ collectionId: "fixture",
+ });
+ expect(session.getSnapshot().status).toBe("authorization_required");
+ expect(connection.applyCollectionSetup).not.toHaveBeenCalled();
+ } finally {
+ session.destroy();
+ }
+ });
+
+ it.each([
+ [{}, "ready"],
+ [{ contracts: [] }, "blocked"],
+ [
+ { setupProblem: "application_declaration_mismatch" },
+ "authorization_required",
+ ],
+ [{ describeProblem: "access_denied" }, "authorization_required"],
+ ])(
+ "checks exact contracts and setup before ready: %j",
+ async (options, status) => {
+ const { session, connection } = await fixture(options);
+ try {
+ expect((await session.start()).ok).toBe(true);
+ expect(session.getSnapshot().status).toBe(status);
+ expect(connection.assessCollectionSetup).toHaveBeenCalledTimes(1);
+ expect(connection.applyCollectionSetup).not.toHaveBeenCalled();
+ if (!options.setupProblem)
+ expect(connection.describe).toHaveBeenCalledTimes(1);
+ if (status === "ready")
+ expect(session.getSnapshot().readiness.contracts.state).toBe(
+ "verified",
+ );
+ } finally {
+ session.destroy();
+ }
+ },
+ );
+});
diff --git a/scripts/production-smoke.mjs b/scripts/production-smoke.mjs
index f1f9c6d..3469d25 100644
--- a/scripts/production-smoke.mjs
+++ b/scripts/production-smoke.mjs
@@ -1,3 +1,5 @@
+import { requiredCapabilities } from "./planner-manifest.mjs";
+
const connectOrigin =
process.env.MDBASE_CONNECT_ORIGIN ?? "https://connect.mdbase.dev";
const appOrigin =
@@ -28,7 +30,10 @@ const checks = [
manifest.redirect_uris?.length !== 1 ||
manifest.redirect_uris[0] !== `${appOrigin}/auth/mdbase/callback` ||
manifest.requirements?.access !== "full_collection" ||
- !manifest.requirements?.capabilities?.required?.includes("views.execute")
+ manifest.requirements?.capabilities?.contract_version !== 2 ||
+ JSON.stringify(manifest.requirements?.capabilities?.required) !==
+ JSON.stringify(requiredCapabilities) ||
+ (manifest.requirements?.capabilities?.optional?.length ?? 0) !== 0
)
throw new Error("The deployed Planner mdbase manifest is invalid.");
if (!validateWithConnect) return;
diff --git a/src/components/connection-gate.test.tsx b/src/components/connection-gate.test.tsx
index f5f40fa..4979888 100644
--- a/src/components/connection-gate.test.tsx
+++ b/src/components/connection-gate.test.tsx
@@ -55,6 +55,7 @@ const connectMock = vi.hoisted(() => {
});
vi.mock("../data/connect", () => ({
+ assertPlannerOrigin: vi.fn(),
isAuthorizationCallback: connectMock.isAuthorizationCallback,
plannerSession: connectMock.session,
}));
@@ -159,6 +160,10 @@ describe("ConnectionGate lifecycle", () => {
const { ConnectionGate } = await import("./connection-gate");
render(
+ Planner needs updated access. Review and approve the new permission + groups to continue; your previous grant will not be upgraded + automatically. +
+ ) : null} {error || stateError ? (
{error || stateError}
diff --git a/src/data/connect.test.ts b/src/data/connect.test.ts
new file mode 100644
index 0000000..4afad80
--- /dev/null
+++ b/src/data/connect.test.ts
@@ -0,0 +1,109 @@
+import {
+ effectiveCapabilities,
+ type MdbaseConnectionInfo,
+ type MdbaseOperation,
+} from "@mdbase-dev/connect";
+import { capabilityOperationsForContractVersion } from "@mdbase-dev/connect-protocol";
+import manifest from "../generated-mdbase-app.json";
+import {
+ assertPlannerOrigin,
+ connect,
+ isAuthorizationCallback,
+ plannerSession,
+} from "./connect";
+
+// Real installed beta95 SDK, not a mocked constructor or capability evaluator.
+describe("Planner's installed Connect runtime", () => {
+ it("constructs the application session and loads the exact v2 declaration without network", async () => {
+ const network = vi
+ .spyOn(globalThis, "fetch")
+ .mockRejectedValue(new Error("Unexpected network"));
+ const result = await connect.manifest();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error(result.problem.message);
+ expect(result.value.requirements?.capabilities).toEqual(
+ manifest.requirements.capabilities,
+ );
+ expect(plannerSession.getSnapshot().status).toBe("not_started");
+ expect(network).not.toHaveBeenCalled();
+ });
+
+ it("evaluates genuine v2 groups, rejects stale v1 operations, and grants no excluded operations", async () => {
+ const result = await connect.manifest();
+ if (!result.ok) throw new Error(result.problem.message);
+ const declaration = result.value;
+ const requirements = declaration.requirements!.capabilities!;
+ const operations = [
+ ...new Set(
+ requirements.required.flatMap(
+ (id) => capabilityOperationsForContractVersion(2, id) ?? [],
+ ),
+ ),
+ ];
+ const info: MdbaseConnectionInfo = {
+ collectionId: "fixture",
+ displayName: "Fixture",
+ operations,
+ scope: { access: "full_collection", contracts: [] },
+ authority: { kind: "connector", durability: "computer" },
+ route: "relay",
+ directAccess: "unavailable",
+ };
+ const effective = effectiveCapabilities(requirements, declaration, info);
+ expect(effective.contractVersion).toBe(2);
+ expect(effective.requiredAvailable).toBe(true);
+ expect(Object.keys(effective.values)).toEqual(
+ manifest.requirements.capabilities.required,
+ );
+ expect(operations).not.toContain("create");
+ expect(operations).not.toContain("delete");
+ expect(operations.some((op) => /file|timer|sync/i.test(op))).toBe(false);
+ // Prior Planner grant's exact operation union is not authority for whole v2 groups.
+ const legacy: MdbaseOperation[] = [
+ "describe",
+ "assess_collection_setup",
+ "apply_collection_setup",
+ "update_type",
+ "assess_type_pack",
+ "apply_type_pack",
+ "changes",
+ "read",
+ "query",
+ "update",
+ "list_views",
+ "execute_view",
+ "read_view_source",
+ "create_view_source",
+ "update_view_source",
+ ];
+ const stale = effectiveCapabilities(requirements, declaration, {
+ ...info,
+ operations: legacy,
+ });
+ expect(stale.requiredAvailable).toBe(false);
+ expect(stale.values["records.edit"]?.state).toBe("requires_authorization");
+ expect(stale.values["views.manage"]?.state).toBe("requires_authorization");
+ const denied = effectiveCapabilities(requirements, declaration, {
+ ...info,
+ operations: [],
+ });
+ expect(denied.requiredAvailable).toBe(false);
+ expect(
+ Object.values(denied.values).every(
+ (value) => value.state === "requires_authorization",
+ ),
+ ).toBe(true);
+ });
+
+ it("requires the manifest origin instead of silently accepting a localhost alias", () => {
+ expect(() => assertPlannerOrigin(manifest.homepage)).not.toThrow();
+ expect(() => assertPlannerOrigin("http://localhost:4174/")).toThrow(
+ /Open Planner at/,
+ );
+ expect(
+ isAuthorizationCallback(
+ `${manifest.redirect_uris[0]}?state=fixture&error=access_denied`,
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/src/data/connect.ts b/src/data/connect.ts
index 0c55303..3f63377 100644
--- a/src/data/connect.ts
+++ b/src/data/connect.ts
@@ -11,9 +11,17 @@ const serverUrl =
import.meta.env.VITE_MDBASE_CONNECT_URL ?? "https://connect.mdbase.dev";
const loopbackUrl =
import.meta.env.VITE_MDBASE_CONNECT_LOOPBACK_URL ?? "http://127.0.0.1:28485";
-const redirectUri = `${location.origin}${joinBase("auth/mdbase/callback")}`;
+// The callback must share the manifest origin (including in development).
+const redirectUri = manifest.redirect_uris[0];
-const connect = new MdbaseConnect