@mdbase-dev/connect-protocol 0.1.0-beta.126 → 0.1.0-beta.148

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.
@@ -67,3 +67,6 @@ export declare function formatManifestValidationIssues(issues: readonly Manifest
67
67
  * prefixed with `path`, the provision's location in its enclosing document.
68
68
  */
69
69
  export declare function validateTypePackProvision(value: unknown, path?: string): ManifestValidationIssue[];
70
+ /** RFC 8252 §7.3 native exception: canonical literal IPv4 only, any valid port.
71
+ * This classifies transport; the registered manifest, consent and pairing grant authority. */
72
+ export declare function isNativeLoopbackOrigin(value: unknown): value is string;
package/dist/manifest.js CHANGED
@@ -1,9 +1,7 @@
1
- import { createHash } from "node:crypto";
2
- import { Ajv2020 } from "ajv/dist/2020.js";
3
- import addFormatsModule from "ajv-formats";
1
+ import { sha256 as sha256Bytes } from "@noble/hashes/sha2.js";
2
+ import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
4
3
  import { isMap, parseDocument } from "yaml";
5
- import appManifestSchema from "../schemas/mdbase-app.schema.json" with { type: "json" };
6
- import legacyAppManifestSchema from "../schemas/mdbase-app.legacy-v1.schema.json" with { type: "json" };
4
+ import { appManifestValidator as generatedAppManifestValidator, legacyAppManifestValidator as generatedLegacyAppManifestValidator } from "./manifest-validators.generated.js";
7
5
  import { RECORD_EXTENSIONS_CONFIGURATION_PATH } from "./collection-setup.js";
8
6
  import { isNativeRedirectUri } from "./index.js";
9
7
  export const APPLICATION_MANIFEST_MAX_BYTES = 524_288;
@@ -15,18 +13,11 @@ export class AppManifestValidationError extends Error {
15
13
  this.name = "AppManifestValidationError";
16
14
  }
17
15
  }
18
- const ajv = new Ajv2020({
19
- allErrors: true,
20
- strict: true,
21
- // `required` may name properties declared by an enclosing oneOf branch.
22
- strictRequired: false
23
- });
24
- const addFormats = addFormatsModule;
25
- addFormats(ajv);
26
- ajv.addSchema(appManifestSchema);
27
- const appManifestValidator = requiredValidator(String(appManifestSchema.$id));
28
- ajv.addSchema(legacyAppManifestSchema);
29
- const legacyAppManifestValidator = requiredValidator(String(legacyAppManifestSchema.$id));
16
+ // Precompiled from the published JSON Schemas by
17
+ // scripts/generate-manifest-validators.mjs, so validation stays synchronous and
18
+ // identical without shipping the Ajv compiler to consumers.
19
+ const appManifestValidator = generatedAppManifestValidator;
20
+ const legacyAppManifestValidator = generatedLegacyAppManifestValidator;
30
21
  /**
31
22
  * Validate the complete bundled application declaration accepted by Connect.
32
23
  *
@@ -55,7 +46,8 @@ function validateManifest(value, options, versioned) {
55
46
  if (version !== 1 && version !== 2) {
56
47
  return invalid([issue("/requirements/capabilities/contract_version", "contractVersion", "must declare supported contract version 1 or 2")]);
57
48
  }
58
- const candidate = options.allowLocal ? localManifestSchemaCandidate(value) : value;
49
+ const native = asObject(value).platform === "native";
50
+ const candidate = options.allowLocal || native ? localManifestSchemaCandidate(value, !options.allowLocal) : value;
59
51
  const schemaResult = validationResult(version === 1 ? legacyAppManifestValidator : appManifestValidator, candidate);
60
52
  if (!schemaResult.valid)
61
53
  return schemaResult;
@@ -104,12 +96,6 @@ function normalizeManifest(value) {
104
96
  export function formatManifestValidationIssues(issues) {
105
97
  return issues.map((issue) => `${issue.path || "/"} ${issue.message}`).join("; ");
106
98
  }
107
- function requiredValidator(reference) {
108
- const validate = ajv.getSchema(reference);
109
- if (!validate)
110
- throw new Error(`Canonical schema is unavailable: ${reference}`);
111
- return validate;
112
- }
113
99
  function validationResult(validate, value) {
114
100
  if (validate(value))
115
101
  return { valid: true, issues: [] };
@@ -192,6 +178,33 @@ function validateJsonValue(value, path, ancestors) {
192
178
  }
193
179
  function validateManifestOrigins(value, allowLocal) {
194
180
  const manifest = asObject(value);
181
+ const native = manifest.platform === "native";
182
+ for (const [environment, policy] of Object.entries(asObject(manifest.installation_origins))) {
183
+ for (const [kind, origins] of Object.entries(asObject(policy))) {
184
+ for (const [index, origin] of origins.entries()) {
185
+ const path = `/installation_origins/${environment}/${kind}/${index}`;
186
+ try {
187
+ if (native && (kind !== "app-runtime" || !isNativeLoopbackOrigin(origin))) {
188
+ return [issue(path, "origin", "native applications require canonical IPv4 loopback HTTP app-runtime origins")];
189
+ }
190
+ const url = new URL(origin);
191
+ const web = (secureOrAllowedLocal(url, environment === "lab")
192
+ || (native && isNativeLoopbackOrigin(origin))) && url.origin === origin;
193
+ const mobile = kind === "mobile" && url.protocol === "capacitor:"
194
+ && /^[a-z0-9.-]+$/u.test(url.hostname) && `${url.protocol}//${url.host}` === origin;
195
+ // A Chrome extension ID is stable (derived from its key), so it can be
196
+ // declared exactly. Firefox extension origins are random per profile.
197
+ const extension = kind === "app-runtime" && /^chrome-extension:\/\/[a-p]{32}$/u.test(origin);
198
+ if ((!web && !mobile && !extension) || origin.includes("*") || url.username || url.password || url.search || url.hash) {
199
+ return [issue(path, "origin", "must be an exact HTTPS, loopback HTTP, mobile capacitor or Chrome extension origin without wildcards")];
200
+ }
201
+ }
202
+ catch {
203
+ return [issue(path, "origin", "must be an exact installation origin")];
204
+ }
205
+ }
206
+ }
207
+ }
195
208
  if (manifest.distribution === "portable") {
196
209
  if (manifest.project_url === undefined) {
197
210
  return manifest.icon === undefined
@@ -215,12 +228,20 @@ function validateManifestOrigins(value, allowLocal) {
215
228
  }
216
229
  try {
217
230
  const homepage = new URL(String(manifest.homepage));
218
- if (!secureOrAllowedLocal(homepage, allowLocal)) {
219
- return [issue("/homepage", "protocol", "must use HTTPS (or loopback HTTP in local mode)")];
231
+ if (native ? !isNativeLoopbackUrl(String(manifest.homepage)) : !secureOrAllowedLocal(homepage, allowLocal)) {
232
+ return [issue("/homepage", "protocol", native ? "native applications require canonical IPv4 loopback HTTP" : "must use HTTPS (or loopback HTTP in local mode)")];
220
233
  }
221
234
  for (const [index, redirect] of manifest.redirect_uris.entries()) {
235
+ // Redirects match exactly (RFC 8252 §8.10); a wildcard is never a pattern.
236
+ if (String(redirect).includes("*")) {
237
+ return [issue(`/redirect_uris/${index}`, "url", "must be an exact URI without wildcards")];
238
+ }
222
239
  const url = new URL(String(redirect));
223
- if (url.origin === homepage.origin && !secureOrAllowedLocal(url, allowLocal)) {
240
+ if (native && ["http:", "https:"].includes(url.protocol) && !isNativeLoopbackUrl(String(redirect))) {
241
+ return [issue(`/redirect_uris/${index}`, "origin", "native HTTP(S) redirects require canonical IPv4 loopback HTTP")];
242
+ }
243
+ if (url.origin === homepage.origin && !secureOrAllowedLocal(url, allowLocal)
244
+ && !(native && isNativeLoopbackUrl(String(redirect)))) {
224
245
  return [issue(`/redirect_uris/${index}`, "protocol", "must use HTTPS (or loopback HTTP in local mode)")];
225
246
  }
226
247
  const nativeAllowed = nativeRedirectMatchesApplication(url, String(manifest.id));
@@ -358,7 +379,7 @@ function typeFrontmatter(document) {
358
379
  return { kind: parsed.get("kind"), name: parsed.get("name"), version: parsed.get("version") };
359
380
  }
360
381
  function sha256(document) {
361
- return `sha256:${createHash("sha256").update(document).digest("hex")}`;
382
+ return `sha256:${bytesToHex(sha256Bytes(utf8ToBytes(document)))}`;
362
383
  }
363
384
  function validateConfigurationSetup(value) {
364
385
  const manifest = asObject(value);
@@ -555,7 +576,7 @@ function validateLegacyCapabilityRequirements(value) {
555
576
  }
556
577
  return issues;
557
578
  }
558
- function localManifestSchemaCandidate(value) {
579
+ function localManifestSchemaCandidate(value, nativeOnly = false) {
559
580
  const source = asObject(value);
560
581
  if (source.distribution === "portable")
561
582
  return value;
@@ -564,18 +585,18 @@ function localManifestSchemaCandidate(value) {
564
585
  const object = { ...source };
565
586
  for (const field of ["homepage", "icon"]) {
566
587
  if (typeof object[field] === "string") {
567
- object[field] = schemaSafeLocalUrl(object[field]);
588
+ object[field] = schemaSafeLocalUrl(object[field], nativeOnly);
568
589
  }
569
590
  }
570
591
  if (Array.isArray(object.redirect_uris)) {
571
- object.redirect_uris = object.redirect_uris.map((url) => typeof url === "string" ? schemaSafeLocalUrl(url) : url);
592
+ object.redirect_uris = object.redirect_uris.map((url) => typeof url === "string" ? schemaSafeLocalUrl(url, nativeOnly) : url);
572
593
  }
573
594
  return object;
574
595
  }
575
- function schemaSafeLocalUrl(value) {
596
+ function schemaSafeLocalUrl(value, nativeOnly) {
576
597
  try {
577
598
  const url = new URL(value);
578
- if (url.protocol === "http:" && isLoopback(url.hostname))
599
+ if (url.protocol === "http:" && (nativeOnly ? isNativeLoopbackUrl(value) : isLoopback(url.hostname)))
579
600
  url.protocol = "https:";
580
601
  return url.href;
581
602
  }
@@ -583,6 +604,22 @@ function schemaSafeLocalUrl(value) {
583
604
  return value;
584
605
  }
585
606
  }
607
+ /** RFC 8252 §7.3 native exception: canonical literal IPv4 only, any valid port.
608
+ * This classifies transport; the registered manifest, consent and pairing grant authority. */
609
+ export function isNativeLoopbackOrigin(value) {
610
+ if (typeof value !== "string" || !/^http:\/\/127\.0\.0\.1(?::[1-9][0-9]{0,4})?$/.test(value))
611
+ return false;
612
+ try {
613
+ return new URL(value).origin === value;
614
+ }
615
+ catch {
616
+ return false;
617
+ }
618
+ }
619
+ function isNativeLoopbackUrl(value) {
620
+ const origin = /^http:\/\/[^/?#]+/.exec(value)?.[0];
621
+ return isNativeLoopbackOrigin(origin);
622
+ }
586
623
  function secureOrAllowedLocal(url, allowLocal) {
587
624
  return url.protocol === "https:"
588
625
  || (allowLocal && url.protocol === "http:" && isLoopback(url.hostname));
@@ -0,0 +1,30 @@
1
+ import { type ApplicationCapabilityId } from "./capabilities.js";
2
+ import type { ApplicationRequirements, LegacyApplicationRequirements } from "./application-declaration.js";
3
+ import type { FileAction } from "./files.js";
4
+ import type { CollectionOperation } from "./operations.js";
5
+ /** Exact record/file pairs used by both consent and next policy publication. */
6
+ export declare const NEXT_CAPABILITY_FILE_ACTIONS: Partial<Record<ApplicationCapabilityId, readonly FileAction[]>>;
7
+ export interface NextAuthorizationGroup {
8
+ id: ApplicationCapabilityId;
9
+ operations: CollectionOperation[];
10
+ fileActions: readonly FileAction[];
11
+ required: boolean;
12
+ }
13
+ type Requirements = {
14
+ access?: ApplicationRequirements["access"];
15
+ capabilities?: ApplicationRequirements["capabilities"] | LegacyApplicationRequirements["capabilities"];
16
+ files?: ApplicationRequirements["files"] | LegacyApplicationRequirements["files"];
17
+ };
18
+ /** Fresh offers only. Never change the original declaration or a retained grant. */
19
+ export declare function projectNextAuthorizationGroups(requirements: Requirements, requested: readonly string[]): {
20
+ groups: NextAuthorizationGroup[];
21
+ error?: string;
22
+ };
23
+ /** Exact projection: no dropping or rounding of stored/approved permissions.
24
+ * Setup is an independent complete pair, not structural administration. This
25
+ * projects ordinary policy groups only; it does not authenticate a declaration
26
+ * or grant authority to execute setup. Producers must verify the original
27
+ * signed declaration/authorization before mediating either setup operation.
28
+ */
29
+ export declare function exactNextPermissionCapabilities(operations: readonly string[], files: readonly string[]): ApplicationCapabilityId[] | undefined;
30
+ export {};
@@ -0,0 +1,90 @@
1
+ import { APPLICATION_CAPABILITY_DEFINITIONS, APPLICATION_CAPABILITY_V1_DEFINITIONS, APPLICATION_SETUP_OPERATIONS } from "./capabilities.js";
2
+ /** Exact record/file pairs used by both consent and next policy publication. */
3
+ export const NEXT_CAPABILITY_FILE_ACTIONS = {
4
+ "collection.read": ["list", "read"], "records.create": ["add"],
5
+ "records.edit": ["replace", "move"], "records.delete": ["delete"]
6
+ };
7
+ const UPDATE_REQUIRED = "This app needs an update before it can connect. Try again after it updates.";
8
+ const refuse = () => ({ groups: [], error: UPDATE_REQUIRED });
9
+ const fileActions = new Set(["list", "read", "add", "replace", "move", "delete"]);
10
+ const equalSet = (left, right) => {
11
+ const a = new Set(left), b = new Set(right);
12
+ return a.size === b.size && [...a].every(value => b.has(value));
13
+ };
14
+ /** Fresh offers only. Never change the original declaration or a retained grant. */
15
+ export function projectNextAuthorizationGroups(requirements, requested) {
16
+ const declaration = requirements.capabilities;
17
+ if (!declaration || (declaration.contract_version !== 1 && declaration.contract_version !== 2)
18
+ || !Array.isArray(declaration.required) || (declaration.optional !== undefined && !Array.isArray(declaration.optional)))
19
+ return refuse();
20
+ const legacy = declaration.contract_version === 1;
21
+ if (legacy && requirements.access !== "full_collection")
22
+ return refuse();
23
+ const catalog = legacy ? APPLICATION_CAPABILITY_V1_DEFINITIONS : APPLICATION_CAPABILITY_DEFINITIONS;
24
+ const ids = [...declaration.required, ...(declaration.optional ?? [])];
25
+ if (ids.some(id => !Object.hasOwn(catalog, id)) || new Set(ids).size !== ids.length)
26
+ return refuse();
27
+ const files = requirements.files;
28
+ if (files && (legacy
29
+ ? !("actions" in files) || "required" in files || "optional" in files || !Array.isArray(files.actions)
30
+ : !("required" in files) || "actions" in files || !Array.isArray(files.required)
31
+ || (files.optional !== undefined && !Array.isArray(files.optional))))
32
+ return refuse();
33
+ const requiredFiles = files ? "actions" in files ? files.actions : files.required : [];
34
+ const declaredFiles = files ? "actions" in files ? files.actions : [...files.required, ...(files.optional ?? [])] : [];
35
+ if (declaredFiles.some(action => !fileActions.has(action)) || new Set(declaredFiles).size !== declaredFiles.length)
36
+ return refuse();
37
+ const requestedSet = new Set(requested);
38
+ const expansions = catalog;
39
+ const requiredOperations = new Set(declaration.required.flatMap(id => expansions[id] ?? []));
40
+ if (legacy) {
41
+ const allowed = new Set(ids.flatMap(id => expansions[id] ?? []));
42
+ if (requested.some(operation => !allowed.has(operation)))
43
+ return refuse();
44
+ // A required file capability cannot disappear just because its operation
45
+ // expansion is empty in the predecessor catalog.
46
+ if (declaration.required.some(id => id.startsWith("files.") && !declaredFiles.includes(id.slice(6))))
47
+ return refuse();
48
+ }
49
+ const candidates = legacy ? Object.keys(APPLICATION_CAPABILITY_DEFINITIONS) : ids;
50
+ const groups = [];
51
+ for (const id of candidates) {
52
+ const operations = [...APPLICATION_CAPABILITY_DEFINITIONS[id]];
53
+ const pairedFiles = NEXT_CAPABILITY_FILE_ACTIONS[id] ?? [];
54
+ const required = operations.some(operation => requiredOperations.has(operation)) || pairedFiles.some(action => requiredFiles.includes(action));
55
+ if (id === "offline.replica" || !operations.every(operation => requestedSet.has(operation))
56
+ || !pairedFiles.every(action => declaredFiles.includes(action))) {
57
+ if (required)
58
+ return refuse();
59
+ continue;
60
+ }
61
+ if (legacy && id === "definitions.manage" && APPLICATION_SETUP_OPERATIONS.every(operation => requestedSet.has(operation)))
62
+ operations.push(...APPLICATION_SETUP_OPERATIONS);
63
+ groups.push({ id, operations, fileActions: pairedFiles, required });
64
+ }
65
+ const covered = new Set(groups.flatMap(group => group.operations));
66
+ if ([...requiredOperations].some(operation => !covered.has(operation))
67
+ || requiredFiles.some(action => !groups.some(group => group.fileActions.includes(action))) || groups.length === 0)
68
+ return refuse();
69
+ return { groups };
70
+ }
71
+ /** Exact projection: no dropping or rounding of stored/approved permissions.
72
+ * Setup is an independent complete pair, not structural administration. This
73
+ * projects ordinary policy groups only; it does not authenticate a declaration
74
+ * or grant authority to execute setup. Producers must verify the original
75
+ * signed declaration/authorization before mediating either setup operation.
76
+ */
77
+ export function exactNextPermissionCapabilities(operations, files) {
78
+ const groups = Object.keys(APPLICATION_CAPABILITY_DEFINITIONS).filter(id => id !== "offline.replica" && APPLICATION_CAPABILITY_DEFINITIONS[id].every(operation => operations.includes(operation)));
79
+ const expected = groups.flatMap(id => [...APPLICATION_CAPABILITY_DEFINITIONS[id]]);
80
+ const setup = APPLICATION_SETUP_OPERATIONS.filter(operation => operations.includes(operation));
81
+ if (setup.length) {
82
+ if (setup.length !== APPLICATION_SETUP_OPERATIONS.length)
83
+ return undefined;
84
+ expected.push(...setup);
85
+ }
86
+ if (!groups.length || !equalSet(expected, operations)
87
+ || !equalSet(groups.flatMap(id => [...(NEXT_CAPABILITY_FILE_ACTIONS[id] ?? [])]), files))
88
+ return undefined;
89
+ return groups.sort();
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mdbase-dev/connect-protocol",
3
- "version": "0.1.0-beta.126",
3
+ "version": "0.1.0-beta.148",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,6 +20,10 @@
20
20
  "types": "./dist/manifest.d.ts",
21
21
  "import": "./dist/manifest.js"
22
22
  },
23
+ "./next-authorization": {
24
+ "types": "./dist/next-authorization.d.ts",
25
+ "import": "./dist/next-authorization.js"
26
+ },
23
27
  "./schemas/*": "./schemas/*"
24
28
  },
25
29
  "files": [
@@ -27,17 +31,19 @@
27
31
  "schemas"
28
32
  ],
29
33
  "dependencies": {
30
- "ajv": "^8.20.0",
31
- "ajv-formats": "^3.0.1",
34
+ "@noble/hashes": "2.4.0",
32
35
  "yaml": "^2.9.1"
33
36
  },
34
37
  "devDependencies": {
35
38
  "@types/node": "^24.0.0",
39
+ "ajv": "^8.20.0",
40
+ "ajv-formats": "^3.0.1",
41
+ "esbuild": "^0.28.1",
36
42
  "typescript": "^7.0.2"
37
43
  },
38
44
  "scripts": {
39
45
  "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
40
- "test": "node --test test/*.test.mjs && node ../../scripts/generate-change-events.mjs --check && node ../../scripts/generate-connect-problems.mjs --check && node ../../scripts/generate-operation-catalog.mjs --check && node ../../scripts/generate-application-capabilities.mjs --check",
46
+ "test": "node --test test/*.test.mjs && node ../../scripts/generate-manifest-validators.mjs --check && node ../../scripts/generate-change-events.mjs --check && node ../../scripts/generate-connect-problems.mjs --check && node ../../scripts/generate-operation-catalog.mjs --check && node ../../scripts/generate-application-capabilities.mjs --check",
41
47
  "typecheck": "tsc -p tsconfig.json --noEmit"
42
48
  }
43
49
  }