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(); + await screen.findByText( + /previous grant will not be upgraded automatically/, + ); + expect(connectMock.session.authorize).not.toHaveBeenCalled(); fireEvent.click( await screen.findByRole("button", { name: "Review updated access" }), ); @@ -170,6 +175,25 @@ describe("ConnectionGate lifecycle", () => { ); }); + it("shows denied consent without falling back or applying setup", async () => { + connectMock.setSnapshot(authorizationRequiredSnapshot()); + connectMock.session.authorize.mockResolvedValue({ + ok: false, + problem: { code: "access_denied", message: "Authorization denied." }, + diagnostics: [], + }); + const { ConnectionGate } = await import("./connection-gate"); + render(); + fireEvent.click( + await screen.findByRole("button", { name: "Review updated access" }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Authorization denied.", + ); + expect(connectMock.session.authorize).toHaveBeenCalledTimes(1); + expect(connectMock.session.applyCollectionSetup).not.toHaveBeenCalled(); + }); + it("omits the selected unusable collection from alternatives", async () => { const selected = authorizationRequiredSnapshot(); connectMock.setSnapshot({ diff --git a/src/components/connection-gate.tsx b/src/components/connection-gate.tsx index deea834..977c8e4 100644 --- a/src/components/connection-gate.tsx +++ b/src/components/connection-gate.tsx @@ -15,7 +15,11 @@ import type { } from "react"; import type { MdbaseApplicationSessionSnapshot } from "@mdbase-dev/connect"; -import { isAuthorizationCallback, plannerSession } from "../data/connect"; +import { + assertPlannerOrigin, + isAuthorizationCallback, + plannerSession, +} from "../data/connect"; import { MdbasePlannerRepository } from "../data/mdbase-repository"; import { errorMessage, requireOutcome } from "../data/outcome"; import { Planner } from "./planner"; @@ -37,6 +41,7 @@ export function ConnectionGate({ onDemo }: { onDemo(): void }) { const startSession = useCallback(async () => { setStartError(""); try { + assertPlannerOrigin(); const timeoutMs = isAuthorizationCallback(location.href) ? CALLBACK_START_TIMEOUT_MS : START_TIMEOUT_MS; @@ -278,6 +283,13 @@ export function ConnectionGate({ onDemo }: { onDemo(): void }) { ))} + {snapshot.status === "authorization_required" ? ( +

+ 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({ +export function assertPlannerOrigin(value = location.href): void { + if (new URL(value).origin !== new URL(manifest.homepage).origin) + throw new Error( + `Open Planner at ${manifest.homepage} to connect a collection.`, + ); +} + +export const connect = new MdbaseConnect({ serverUrl, loopbackUrl, manifest: manifest as MdbaseAppManifest, diff --git a/src/data/mdbase-repository.test.ts b/src/data/mdbase-repository.test.ts index daba1bf..1ba6e99 100644 --- a/src/data/mdbase-repository.test.ts +++ b/src/data/mdbase-repository.test.ts @@ -27,10 +27,9 @@ describe("MdbasePlannerRepository", () => { effectiveFrontmatter: { id: "plan", title: "Plan launch", - status: input.patch.status, + status: "open", priority: "normal", - projects: input.patch.projects, - completedDate: input.patch.completedDate, + ...input.patch, }, }), ); @@ -144,6 +143,11 @@ describe("MdbasePlannerRepository", () => { const repository = new MdbasePlannerRepository(connection); const collection = await repository.load(); + const contract = { id: "tasknotes.task", version: "0.3.0-rc.3" }; + expect(connection.queryAll).toHaveBeenCalledWith( + expect.objectContaining({ contract }), + { firstPageSize: 500, pageSize: 1_000 }, + ); expect(collection.statuses).toMatchObject([ { value: "open", label: "Open", isCompleted: false }, { value: "done", label: "Done", isCompleted: true }, @@ -167,6 +171,20 @@ describe("MdbasePlannerRepository", () => { patch: expect.objectContaining({ projects: ["[[Launch]]"] }), }), ); + const schedule = { scheduled: "2026-09-09", due: "2026-09-12" }; + expect( + await repository.updateSchedule(collection.tasks[0], schedule), + ).toMatchObject(schedule); + expect(connection.read).toHaveBeenLastCalledWith({ + path: "tasks/plan.md", + contract: { ...contract, type: "task" }, + }); + expect(update).toHaveBeenLastCalledWith({ + path: "tasks/plan.md", + contract: { ...contract, type: "task" }, + ifRevision: "task-1", + patch: schedule, + }); await repository.saveView({ name: "High work", status: "open", @@ -196,6 +214,65 @@ describe("MdbasePlannerRepository", () => { ); }); + it("updates a saved Base with its revision and preserves other views and options", async () => { + const path = "TaskNotes/Views/work.base"; + const readViewSource = vi.fn(async () => + success({ + path, + revision: "view-3", + document: JSON.stringify({ + formulas: { effort: "1 + 1" }, + views: [ + { type: "table", name: "Other" }, + { + type: "tasknotesPlanner", + name: "Work", + options: { custom: "preserved", zoom: 2 }, + }, + ], + }), + }), + ); + const updateViewSource = vi.fn(async (input: { document: string }) => + success({ path, format: "obsidian.base", revision: "view-4", ...input }), + ); + const createViewSource = vi.fn(); + const repository = new MdbasePlannerRepository({ + readViewSource, + updateViewSource, + createViewSource, + } as unknown as MdbaseConnection); + const saved = await repository.saveView({ + name: "Work", + zoom: 4, + view: { + key: `${path}#work`, + path, + id: "work", + name: "Work", + format: "obsidian.base", + revision: "view-1", + writable: true, + options: {}, + }, + }); + expect(readViewSource).toHaveBeenCalledWith({ path }); + expect(updateViewSource).toHaveBeenCalledWith({ + path, + ifRevision: "view-3", + document: expect.any(String), + }); + expect(parse(updateViewSource.mock.calls[0][0].document)).toMatchObject({ + formulas: { effort: "1 + 1" }, + views: [ + { type: "table", name: "Other" }, + { type: "tasknotesPlanner", options: { custom: "preserved", zoom: 4 } }, + ], + }); + expect(saved.revision).toBe("view-4"); + expect(createViewSource).not.toHaveBeenCalled(); + }); + it("recovers the persisted mutation handle without replaying a write", async () => { const recover = vi.fn(async () => success({ revision: "task-2" })); const pending = { diff --git a/src/generated-mdbase-app.json b/src/generated-mdbase-app.json index 7720b56..87b69f4 100644 --- a/src/generated-mdbase-app.json +++ b/src/generated-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/vendor/mdbase-connect-sdk.json b/vendor/mdbase-connect-sdk.json index 443e254..b4e1e51 100644 --- a/vendor/mdbase-connect-sdk.json +++ b/vendor/mdbase-connect-sdk.json @@ -1,19 +1,25 @@ { "schema_version": 1, "repository": "https://github.com/mdbase-dev/mdbase-connect", - "revision": "61daa25dfbb6f32fef0840a75d5514fe9b81bd29", + "revision": "56ed32ffde055d2ab2b22ff95722df8ef06bdb1d", "artifacts": [ { "package": "connect", - "file": "mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz", - "bytes": 177886, - "sha512": "4KvCC9UGfAdfOKFFKIrQPFNq5w/14bktuPbGB3EJlKnYsP1V+qVxpyHt9SzpgqSyTaRLpigCTxTixZrT/aW0Yg==" + "file": "mdbase-dev-connect-0.1.0-beta.96-56ed32ffde05.tgz", + "bytes": 188313, + "sha512": "7k2TL4IVu4IyWSs+1DuSDrY/Vw0vAHlpgL3Zq9R/Bb1I61TMwTTQ2p/aYx400FT69qQGO1CA6SHs7oHNroHh5w==" }, { "package": "protocol", - "file": "mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz", - "bytes": 56602, - "sha512": "+lXIWlLhItrHXiVHUfs4DIuo7U0h1Z7GByha9lyAoNDe+TDi9RY0x+koUTTkwKvn7AqjWRVAxfLqpdetiK7Thg==" + "file": "mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz", + "bytes": 61051, + "sha512": "prz8clP0Vpu8HQ4ucr+ss4am2sLQpFTOjXBSRcaU8/RIdDM7tZurOYHP/y0LdDkViJ9pODfD6R82umpdvMQYhQ==" + }, + { + "package": "sync", + "file": "mdbase-dev-connect-sync-0.1.0-beta.96-56ed32ffde05.tgz", + "bytes": 100340, + "sha512": "4NsAGAFsWzMx7DzEzQX2vc0KyE01S9bWSSvGkrCB56lew7cOHz50FJebPglfvbs4Z7mIIsu6Cja6A/4Le1320g==" } ] } diff --git a/vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz b/vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz deleted file mode 100644 index 4157ce4..0000000 Binary files a/vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz and /dev/null differ diff --git a/vendor/mdbase-dev-connect-0.1.0-beta.96-56ed32ffde05.tgz b/vendor/mdbase-dev-connect-0.1.0-beta.96-56ed32ffde05.tgz new file mode 100644 index 0000000..070bdae Binary files /dev/null and b/vendor/mdbase-dev-connect-0.1.0-beta.96-56ed32ffde05.tgz differ diff --git a/vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz b/vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz deleted file mode 100644 index cf9c513..0000000 Binary files a/vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz and /dev/null differ diff --git a/vendor/mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz b/vendor/mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz new file mode 100644 index 0000000..c0fec41 Binary files /dev/null and b/vendor/mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz differ diff --git a/vendor/mdbase-dev-connect-sync-0.1.0-beta.96-56ed32ffde05.tgz b/vendor/mdbase-dev-connect-sync-0.1.0-beta.96-56ed32ffde05.tgz new file mode 100644 index 0000000..f1c06be Binary files /dev/null and b/vendor/mdbase-dev-connect-sync-0.1.0-beta.96-56ed32ffde05.tgz differ