@mdbase-dev/connect 0.1.0-beta.27 → 0.1.0-beta.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,8 +22,7 @@ const mdbase = new MdbaseConnect({
22
22
  manifest: new URL(".well-known/mdbase-app.json", location.href).href,
23
23
  redirectUri: "https://workouts.example/auth/mdbase/callback"
24
24
  });
25
- const session = mdbase.createSession({
26
- operations: ["describe", "changes", "read", "query", "update"],
25
+ const session = mdbase.createApplicationSession({
27
26
  selection: new MdbaseBrowserSelection()
28
27
  });
29
28
 
@@ -33,14 +32,7 @@ if (!started.ok) {
33
32
  return;
34
33
  }
35
34
  if (session.getSnapshot().status === "unselected") {
36
- const authorized = await session.authorize("choose", {
37
- onFirstContact: ({ authenticationString }) => {
38
- showPersistentSecurityPrompt({
39
- title: "Compare with mdbase connect",
40
- code: authenticationString
41
- });
42
- }
43
- });
35
+ const authorized = await session.authorize("choose");
44
36
  if (!authorized.ok) {
45
37
  renderProblem(authorized.problem);
46
38
  return;
@@ -49,7 +41,8 @@ if (session.getSnapshot().status === "unselected") {
49
41
 
50
42
  const snapshot = session.getSnapshot();
51
43
  if (snapshot.status !== "ready") throw new Error("Choose an authorized collection.");
52
- const connection = snapshot.connection;
44
+ const connection = session.connection();
45
+ if (!connection) throw new Error("The ready session has no active connection.");
53
46
  const queried = await connection.query({ types: ["workout"] });
54
47
  if (!queried.ok) {
55
48
  renderProblem(queried.problem);
@@ -78,12 +71,32 @@ reserved for programming errors and broken SDK invariants. See
78
71
  problem model, setup failures, mutation uncertainty, and UI guidance.
79
72
 
80
73
  `MdbaseConnect` is the application-level authorization registry.
81
- `MdbaseSession` is the normal application boundary: it owns active collection
82
- selection, authorization completion, access capabilities, and one
83
- reactive snapshot. Use `getSnapshot()` and `subscribe()` directly or through
84
- your framework's external-store integration. A snapshot is `unselected`,
85
- `ready`, or `unavailable`; an unavailable bookmark includes the explicit
86
- collection ID and reason instead of collapsing every recovery state to `null`.
74
+ `MdbaseApplicationSession` is the normal application boundary: it owns active
75
+ collection selection, authorization completion, semantic capabilities,
76
+ definition compatibility, and one reactive snapshot. Applications declare
77
+ versioned capabilities in their manifest; they never maintain a parallel array
78
+ of protocol operations. Use `getSnapshot()` and `subscribe()` directly or
79
+ through your framework's external-store integration.
80
+
81
+ The session distinguishes `authorization_required`, `checking_definitions`,
82
+ `definition_review_required`, `ready`, `unavailable`, and `blocked`. Definition
83
+ inspection is read-only. If an update is needed, render the supplied plan and
84
+ apply the exact assessment the user reviewed:
85
+
86
+ ```ts
87
+ const snapshot = session.getSnapshot();
88
+ if (snapshot.status === "definition_review_required") {
89
+ renderDefinitionChanges(snapshot.updates);
90
+ const applied = await session.applyDefinitionUpdates();
91
+ if (!applied.ok) renderProblem(applied.problem);
92
+ }
93
+ ```
94
+
95
+ A `ready` snapshot may be `cached` for immediate startup and is then reverified
96
+ in the background. Route details remain diagnostics; feature UI should read
97
+ `snapshot.capabilities`, whose values explain whether a capability is available,
98
+ needs authorization/setup, is temporarily unavailable, or is unsupported by the
99
+ selected authority.
87
100
 
88
101
  `MdbaseBrowserSelection` keeps the stable collection ID in
89
102
  `?collection=<id>`, preserves unrelated path/query/hash and router state, and
@@ -111,15 +124,14 @@ Collection IDs are opaque non-secret locators that may appear in browser
111
124
  history and logs. Grants remain the authorization boundary, and names are
112
125
  display text that may change.
113
126
 
114
- When the chosen collection is owned by a computer that has not seen this
115
- application installation before, `onFirstContact` is required. The SDK derives
116
- the eight-character value locally from the application's non-extractable
117
- installation key and the connector key; it never accepts a value supplied by
118
- the control plane. Keep the value visible while the user compares and accepts
119
- the exact same value in mdbase connect. Headless operators use
120
- `mdbase connect trust list`, `trust show <id>`, and
121
- `trust accept <id> --code XXXX-XXXX`. Hosted-only authorization does not invoke
122
- this callback.
127
+ The SDK keeps one non-extractable P-256 installation signing identity per
128
+ server/application and signs every authorization ceiling with it. Grant
129
+ agreement and signing keys remain disposable and are replaced on
130
+ reauthorization. For local collections, the SDK also pins the connector public
131
+ key after the first successful authorization and rejects silent replacement as
132
+ `connector_identity_changed`, even after the grant is forgotten. Only call
133
+ `forgetConnectorIdentity(connectorId)` after independently verifying an
134
+ intentional connector replacement.
123
135
 
124
136
  Bundled v1 application manifests can declare runtime notification criteria.
125
137
  Register a service worker from a user gesture to receive standards-based Web
@@ -423,10 +435,11 @@ allows a host to choose another persistence boundary.
423
435
  Native shells can pass `navigate` to open the approval URL in the system
424
436
  browser and list a reverse-domain callback such as
425
437
  `dev.mdbase.worklog://auth/mdbase/callback` in the bundled declaration. Its
426
- scheme must match the declaration ID. The application remains alive, polls the
427
- PKCE-bound status endpoint, displays first contact when required, and receives
428
- the completed connection directly from `authorize()`; the browser does not
429
- carry credentials back through a deep link.
438
+ scheme must match the declaration ID. `authorize()` returns `redirecting`; the
439
+ browser redirects to the declared callback with a short-lived code and state,
440
+ and the shell passes that URL to `completeAuthorization()`. PKCE prevents the
441
+ callback code from being exchanged without the pending application state and
442
+ verifier.
430
443
 
431
444
  New authorizations require encrypted relay protocol 1 by default. The SDK keeps
432
445
  independent non-extractable P-256 ECDH agreement and ECDSA signing keys plus an
@@ -1,5 +1,34 @@
1
1
  import { authorizationSigningMessage, type ApplicationAuthorizationBinding, type ApplicationAuthorizationProof } from "@mdbase-dev/connect-protocol";
2
- import type { GrantKeyRecord } from "./crypto.js";
2
+ import type { Application } from "./internal-types.js";
3
+ export interface ApplicationIdentity {
4
+ handle: string;
5
+ signingPrivateKey: CryptoKey;
6
+ signingPublicKey: string;
7
+ }
8
+ export interface ApplicationIdentityStore {
9
+ create(handle: string): Promise<ApplicationIdentity>;
10
+ get(handle: string): Promise<ApplicationIdentity | null>;
11
+ delete(handle: string): Promise<void>;
12
+ }
13
+ /** Origin-scoped persistence for one non-extractable signing key per app/server installation. */
14
+ export declare class IndexedDbApplicationIdentityStore implements ApplicationIdentityStore {
15
+ private database;
16
+ create(handle: string): Promise<ApplicationIdentity>;
17
+ get(handle: string): Promise<ApplicationIdentity | null>;
18
+ delete(handle: string): Promise<void>;
19
+ private open;
20
+ }
21
+ /** Process-local identity storage for portable applications, tests, and custom hosts. */
22
+ export declare class MemoryApplicationIdentityStore implements ApplicationIdentityStore {
23
+ private readonly records;
24
+ create(handle: string): Promise<ApplicationIdentity>;
25
+ get(handle: string): Promise<ApplicationIdentity | null>;
26
+ delete(handle: string): Promise<void>;
27
+ }
28
+ export declare class ApplicationIdentityStoreError extends Error {
29
+ constructor(message: string);
30
+ }
3
31
  export { authorizationSigningMessage };
4
- export declare function applicationInstallationId(identity: Pick<GrantKeyRecord, "agreementPublicKey" | "signingPublicKey">): Promise<string>;
5
- export declare function signApplicationAuthorization(binding: ApplicationAuthorizationBinding, identity: Pick<GrantKeyRecord, "signingPrivateKey" | "agreementPublicKey" | "signingPublicKey">): Promise<ApplicationAuthorizationProof>;
32
+ export declare function applicationInstallationId(identity: Pick<ApplicationIdentity, "signingPublicKey">): Promise<string>;
33
+ export declare function signApplicationAuthorization(binding: ApplicationAuthorizationBinding, identity: Pick<ApplicationIdentity, "signingPrivateKey" | "signingPublicKey">): Promise<ApplicationAuthorizationProof>;
34
+ export declare function applicationIdentity(store: ApplicationIdentityStore, serverUrl: string, application: Application): Promise<ApplicationIdentity>;
@@ -1,21 +1,115 @@
1
- import { applicationInstallationIdFromPublicKeys, authorizationSigningMessage } from "@mdbase-dev/connect-protocol";
1
+ import { applicationInstallationIdFromPublicKey, authorizationSigningMessage } from "@mdbase-dev/connect-protocol";
2
+ const DATABASE_NAME = "mdbase-connect-identities";
3
+ const STORE_NAME = "application-identities";
2
4
  const P256_ORDER = BigInt("0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551");
3
5
  const P256_HALF_ORDER = P256_ORDER / 2n;
6
+ /** Origin-scoped persistence for one non-extractable signing key per app/server installation. */
7
+ export class IndexedDbApplicationIdentityStore {
8
+ database = null;
9
+ async create(handle) {
10
+ const record = await generateApplicationIdentity(handle);
11
+ const database = await this.open();
12
+ await idbWrite(database, (store) => store.add(record));
13
+ return record;
14
+ }
15
+ async get(handle) {
16
+ const database = await this.open();
17
+ return await idbRequest(database.transaction(STORE_NAME).objectStore(STORE_NAME).get(handle)) ?? null;
18
+ }
19
+ async delete(handle) {
20
+ const database = await this.open();
21
+ await idbWrite(database, (store) => store.delete(handle));
22
+ }
23
+ open() {
24
+ if (typeof indexedDB === "undefined") {
25
+ throw new ApplicationIdentityStoreError("Persistent application identity requires browser IndexedDB or an injected identity store.");
26
+ }
27
+ if (!this.database) {
28
+ this.database = new Promise((resolve, reject) => {
29
+ const request = indexedDB.open(DATABASE_NAME, 1);
30
+ request.onupgradeneeded = () => {
31
+ if (!request.result.objectStoreNames.contains(STORE_NAME)) {
32
+ request.result.createObjectStore(STORE_NAME, { keyPath: "handle" });
33
+ }
34
+ };
35
+ request.onerror = () => reject(request.error);
36
+ request.onsuccess = () => resolve(request.result);
37
+ });
38
+ }
39
+ return this.database;
40
+ }
41
+ }
42
+ /** Process-local identity storage for portable applications, tests, and custom hosts. */
43
+ export class MemoryApplicationIdentityStore {
44
+ records = new Map();
45
+ async create(handle) {
46
+ if (this.records.has(handle))
47
+ throw new Error(`Application identity already exists: ${handle}`);
48
+ const record = await generateApplicationIdentity(handle);
49
+ this.records.set(handle, record);
50
+ return record;
51
+ }
52
+ async get(handle) {
53
+ return this.records.get(handle) ?? null;
54
+ }
55
+ async delete(handle) {
56
+ this.records.delete(handle);
57
+ }
58
+ }
59
+ export class ApplicationIdentityStoreError extends Error {
60
+ constructor(message) {
61
+ super(message);
62
+ this.name = "ApplicationIdentityStoreError";
63
+ }
64
+ }
4
65
  export { authorizationSigningMessage };
5
66
  export async function applicationInstallationId(identity) {
6
- return applicationInstallationIdFromPublicKeys(identity.agreementPublicKey, identity.signingPublicKey);
67
+ return applicationInstallationIdFromPublicKey(identity.signingPublicKey);
7
68
  }
8
69
  export async function signApplicationAuthorization(binding, identity) {
9
- if (binding.installation_agreement_public_key !== identity.agreementPublicKey
10
- || binding.installation_signing_public_key !== identity.signingPublicKey
70
+ if (binding.installation_signing_public_key !== identity.signingPublicKey
11
71
  || binding.application_installation_id !== await applicationInstallationId(identity)) {
12
72
  throw new Error("Application authorization identity does not match its binding.");
13
73
  }
14
74
  const signature = canonicalSignature(new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, identity.signingPrivateKey, authorizationSigningMessage(binding))));
15
- return {
16
- binding,
17
- signature: base64Url(signature)
18
- };
75
+ return { binding, signature: base64Url(signature) };
76
+ }
77
+ export async function applicationIdentity(store, serverUrl, application) {
78
+ const handle = `application-installation:v2:${serverUrl}:${application.id}`;
79
+ const existing = await store.get(handle);
80
+ if (existing)
81
+ return existing;
82
+ try {
83
+ return await store.create(handle);
84
+ }
85
+ catch (error) {
86
+ const raced = await store.get(handle);
87
+ if (raced)
88
+ return raced;
89
+ throw error;
90
+ }
91
+ }
92
+ async function generateApplicationIdentity(handle) {
93
+ const signing = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
94
+ const signingPublicKey = base64Url(new Uint8Array(await crypto.subtle.exportKey("raw", signing.publicKey)));
95
+ const signingPkcs8 = await crypto.subtle.exportKey("pkcs8", signing.privateKey);
96
+ const signingPrivateKey = await crypto.subtle.importKey("pkcs8", signingPkcs8, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
97
+ return { handle, signingPrivateKey, signingPublicKey };
98
+ }
99
+ function idbRequest(request) {
100
+ return new Promise((resolve, reject) => {
101
+ request.onerror = () => reject(request.error);
102
+ request.onsuccess = () => resolve(request.result);
103
+ });
104
+ }
105
+ function idbWrite(database, write) {
106
+ return new Promise((resolve, reject) => {
107
+ const transaction = database.transaction(STORE_NAME, "readwrite");
108
+ write(transaction.objectStore(STORE_NAME)).onerror = () => reject(transaction.error);
109
+ transaction.onerror = () => reject(transaction.error);
110
+ transaction.onabort = () => reject(transaction.error ?? new Error("Identity transaction aborted."));
111
+ transaction.oncomplete = () => resolve();
112
+ });
19
113
  }
20
114
  function concat(values) {
21
115
  const output = new Uint8Array(values.reduce((length, value) => length + value.byteLength, 0));
@@ -33,13 +127,11 @@ function base64Url(value) {
33
127
  .replace(/=+$/u, "");
34
128
  }
35
129
  function canonicalSignature(signature) {
36
- if (signature.byteLength !== 64) {
130
+ if (signature.byteLength !== 64)
37
131
  throw new Error("Application authorization signature is invalid.");
38
- }
39
132
  const s = bytesBigInt(signature.slice(32));
40
- if (s === 0n || s >= P256_ORDER) {
133
+ if (s === 0n || s >= P256_ORDER)
41
134
  throw new Error("Application authorization signature is invalid.");
42
- }
43
135
  if (s <= P256_HALF_ORDER)
44
136
  return signature;
45
137
  return concat([signature.slice(0, 32), bigIntBytes(P256_ORDER - s, 32)]);
@@ -0,0 +1,120 @@
1
+ import { type ApplicationCapabilityId, type JsonObject, type MdbaseAppManifest, type TypePackAssessment } from "@mdbase-dev/connect-protocol";
2
+ import { type MdbaseEffectiveCapabilities } from "./capabilities.js";
3
+ import type { MdbaseAuthorizationOutcome, MdbaseAuthorizeOptions, MdbaseConnection } from "./connection.js";
4
+ import type { MdbaseConnectionInfo } from "./connection-types.js";
5
+ import { type ConnectOutcome, type CollectionTypeProblemCode, type AuthorizationProblemCode, type RegistrationProblemCode, type SessionProblemCode } from "./outcomes.js";
6
+ import type { MdbaseApplicationSelection, MdbaseSelectionHistory } from "./selection.js";
7
+ import { type MdbaseSessionConnect, type MdbaseUnavailableReason } from "./session.js";
8
+ export interface MdbaseApplicationSessionConnect<Frontmatter extends JsonObject = JsonObject> extends MdbaseSessionConnect<Frontmatter> {
9
+ manifest(): Promise<ConnectOutcome<MdbaseAppManifest, RegistrationProblemCode>>;
10
+ }
11
+ export interface MdbaseApplicationSessionOptions {
12
+ selection: MdbaseApplicationSelection;
13
+ autoSelect?: "only" | "never";
14
+ verificationStore?: MdbaseApplicationVerificationStore;
15
+ }
16
+ export interface MdbaseApplicationVerificationStore {
17
+ read(key: string): string | null;
18
+ write(key: string, value: string): void;
19
+ remove(key: string): void;
20
+ }
21
+ export declare class MdbaseMemoryVerificationStore implements MdbaseApplicationVerificationStore {
22
+ private readonly values;
23
+ read(key: string): string | null;
24
+ write(key: string, value: string): void;
25
+ remove(key: string): void;
26
+ }
27
+ export interface MdbaseDefinitionUpdate {
28
+ id: string;
29
+ name: string;
30
+ status: TypePackAssessment["status"];
31
+ applicable: boolean;
32
+ assessmentDigest: string;
33
+ currentVersion?: string;
34
+ desiredVersion: string;
35
+ resources: TypePackAssessment["resources"];
36
+ contractSetups: TypePackAssessment["contract_setups"];
37
+ canApply: boolean;
38
+ reason: string;
39
+ }
40
+ interface ApplicationSessionContext {
41
+ collectionId: string;
42
+ info: MdbaseConnectionInfo;
43
+ capabilities: MdbaseEffectiveCapabilities;
44
+ connections: MdbaseConnectionInfo[];
45
+ }
46
+ export type MdbaseApplicationSessionSnapshot = {
47
+ status: "opening";
48
+ connections: MdbaseConnectionInfo[];
49
+ } | {
50
+ status: "unselected";
51
+ connections: MdbaseConnectionInfo[];
52
+ } | ({
53
+ status: "authorization_required";
54
+ } & ApplicationSessionContext) | ({
55
+ status: "checking_definitions";
56
+ } & ApplicationSessionContext) | ({
57
+ status: "definition_review_required";
58
+ updates: MdbaseDefinitionUpdate[];
59
+ } & ApplicationSessionContext) | ({
60
+ status: "ready";
61
+ verification: "cached" | "verified";
62
+ } & ApplicationSessionContext) | {
63
+ status: "unavailable";
64
+ collectionId: string;
65
+ reason: MdbaseUnavailableReason;
66
+ connections: MdbaseConnectionInfo[];
67
+ } | ({
68
+ status: "blocked";
69
+ problem: {
70
+ code: string;
71
+ message: string;
72
+ recovery?: string;
73
+ };
74
+ } & ApplicationSessionContext);
75
+ /**
76
+ * The application-level lifecycle boundary. It derives authorization from the
77
+ * bundled manifest, exposes semantic capabilities, and never mutates collection
78
+ * definitions without an explicit `applyDefinitionUpdates` call.
79
+ */
80
+ export declare class MdbaseApplicationSession<Frontmatter extends JsonObject = JsonObject> {
81
+ private readonly connect;
82
+ private readonly options;
83
+ private readonly listeners;
84
+ private readonly verificationStore;
85
+ private snapshot;
86
+ private manifest;
87
+ private base;
88
+ private stopBase?;
89
+ private reviewEntries;
90
+ private verificationGeneration;
91
+ constructor(connect: MdbaseApplicationSessionConnect<Frontmatter>, options: MdbaseApplicationSessionOptions);
92
+ start(): Promise<ConnectOutcome<MdbaseApplicationSessionSnapshot, SessionProblemCode>>;
93
+ destroy(): void;
94
+ getSnapshot(): MdbaseApplicationSessionSnapshot;
95
+ subscribe(listener: () => void): () => void;
96
+ connection(): MdbaseConnection<Frontmatter> | null;
97
+ select(collectionId: string, options?: {
98
+ history?: MdbaseSelectionHistory;
99
+ }): ConnectOutcome<MdbaseConnection<Frontmatter>, "unknown_collection">;
100
+ clearSelection(options?: {
101
+ history?: MdbaseSelectionHistory;
102
+ }): void;
103
+ forget(collectionId: string): void;
104
+ authorize(target: "choose" | "selected" | {
105
+ collectionId: string;
106
+ }, options?: Omit<MdbaseAuthorizeOptions, "operations" | "returnTo" | "target">): Promise<ConnectOutcome<MdbaseAuthorizationOutcome<Frontmatter>, SessionProblemCode>>;
107
+ handleAuthorizationCallback(callbackUrl: string): Promise<ConnectOutcome<MdbaseConnection<Frontmatter>, AuthorizationProblemCode>>;
108
+ ensureCapabilities(capabilities: ApplicationCapabilityId[]): Promise<ConnectOutcome<MdbaseAuthorizationOutcome<Frontmatter> | {
109
+ kind: "unchanged";
110
+ connection: MdbaseConnection<Frontmatter>;
111
+ }, SessionProblemCode>>;
112
+ applyDefinitionUpdates(): Promise<ConnectOutcome<MdbaseApplicationSessionSnapshot, CollectionTypeProblemCode | "collection_not_ready">>;
113
+ private refresh;
114
+ private verifyDefinitions;
115
+ private context;
116
+ private publish;
117
+ private requireBase;
118
+ private requireManifest;
119
+ }
120
+ export {};