@mdbase-dev/connect-protocol 0.1.0-beta.28 → 0.1.0-beta.29

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.
@@ -463,7 +463,14 @@ export interface ConnectProblemDetailsByCode {
463
463
  "missing_operations": Array<string>;
464
464
  };
465
465
  "invalid_application_authorization": undefined;
466
- "invalid_application_manifest": undefined;
466
+ "invalid_application_manifest": {
467
+ "issues": Array<{
468
+ "path": string;
469
+ "keyword": string;
470
+ "message": string;
471
+ "params": Record<string, unknown>;
472
+ }>;
473
+ };
467
474
  "invalid_callback": undefined;
468
475
  "invalid_device_authorization_response": undefined;
469
476
  "invalid_encrypted_response": undefined;
@@ -784,7 +791,7 @@ export interface ConnectProblemByCode {
784
791
  code: "invalid_application_manifest";
785
792
  category: "validation";
786
793
  recovery: "fix_request";
787
- details?: never;
794
+ details: ConnectProblemDetailsByCode["invalid_application_manifest"];
788
795
  };
789
796
  "invalid_callback": ConnectProblemBase & {
790
797
  code: "invalid_callback";
@@ -254,6 +254,40 @@ const CONNECT_PROBLEM_DETAIL_SCHEMAS = {
254
254
  }
255
255
  }
256
256
  },
257
+ "invalid_application_manifest": {
258
+ "type": "object",
259
+ "required": [
260
+ "issues"
261
+ ],
262
+ "properties": {
263
+ "issues": {
264
+ "type": "array",
265
+ "items": {
266
+ "type": "object",
267
+ "required": [
268
+ "path",
269
+ "keyword",
270
+ "message",
271
+ "params"
272
+ ],
273
+ "properties": {
274
+ "path": {
275
+ "type": "string"
276
+ },
277
+ "keyword": {
278
+ "type": "string"
279
+ },
280
+ "message": {
281
+ "type": "string"
282
+ },
283
+ "params": {
284
+ "type": "object"
285
+ }
286
+ }
287
+ }
288
+ }
289
+ }
290
+ },
257
291
  "operation_invalid": {
258
292
  "type": "object",
259
293
  "required": [
@@ -0,0 +1,45 @@
1
+ import { type ApplicationNotifications, type ApplicationProvisions, type ApplicationRequirements, type MdbasePortableAppManifest, type MdbaseWebAppManifest } from "./index.js";
2
+ export declare const APPLICATION_MANIFEST_MAX_BYTES = 524288;
3
+ export interface ManifestValidationIssue {
4
+ path: string;
5
+ keyword: string;
6
+ message: string;
7
+ params: Record<string, unknown>;
8
+ }
9
+ export type ManifestValidationResult = {
10
+ valid: true;
11
+ issues: [];
12
+ } | {
13
+ valid: false;
14
+ issues: ManifestValidationIssue[];
15
+ };
16
+ export interface ManifestValidationOptions {
17
+ /** Allow HTTP only for localhost/loopback developer web manifests. */
18
+ allowLocal?: boolean;
19
+ /** Maximum UTF-8 size of the complete declaration. */
20
+ maxBytes?: number;
21
+ }
22
+ interface ValidatedManifestFields {
23
+ requirements: ApplicationRequirements;
24
+ provisions: ApplicationProvisions;
25
+ notifications: ApplicationNotifications;
26
+ }
27
+ export type ValidatedWebAppManifest = Omit<MdbaseWebAppManifest, keyof ValidatedManifestFields> & ValidatedManifestFields;
28
+ export type ValidatedPortableAppManifest = Omit<MdbasePortableAppManifest, keyof ValidatedManifestFields> & ValidatedManifestFields;
29
+ export type ValidatedAppManifest = ValidatedWebAppManifest | ValidatedPortableAppManifest;
30
+ export declare class AppManifestValidationError extends Error {
31
+ readonly issues: ManifestValidationIssue[];
32
+ constructor(issues: ManifestValidationIssue[]);
33
+ }
34
+ /**
35
+ * Validate the complete bundled application declaration accepted by Connect.
36
+ *
37
+ * This is the canonical runtime validator used by the server and developer
38
+ * tooling. Keep structural rules in the published JSON Schema and cross-field
39
+ * rules here; consumers must not maintain parallel manifest validators.
40
+ */
41
+ export declare function validateAppManifest(value: unknown, options?: ManifestValidationOptions): ManifestValidationResult;
42
+ /** Validate, normalize optional sections, and return an isolated declaration. */
43
+ export declare function parseAppManifest(value: unknown, options?: ManifestValidationOptions): ValidatedAppManifest;
44
+ export declare function formatManifestValidationIssues(issues: readonly ManifestValidationIssue[]): string;
45
+ export {};
@@ -0,0 +1,355 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Ajv2020 } from "ajv/dist/2020.js";
3
+ import addFormatsModule from "ajv-formats";
4
+ import appManifestSchema from "../schemas/mdbase-app.schema.json" with { type: "json" };
5
+ import { isNativeRedirectUri } from "./index.js";
6
+ export const APPLICATION_MANIFEST_MAX_BYTES = 524_288;
7
+ export class AppManifestValidationError extends Error {
8
+ issues;
9
+ constructor(issues) {
10
+ super(`Invalid application manifest: ${formatManifestValidationIssues(issues)}`);
11
+ this.issues = issues;
12
+ this.name = "AppManifestValidationError";
13
+ }
14
+ }
15
+ const ajv = new Ajv2020({
16
+ allErrors: true,
17
+ strict: true,
18
+ // `required` may name properties declared by an enclosing oneOf branch.
19
+ strictRequired: false
20
+ });
21
+ const addFormats = addFormatsModule;
22
+ addFormats(ajv);
23
+ ajv.addSchema(appManifestSchema);
24
+ const appManifestValidator = requiredValidator(String(appManifestSchema.$id));
25
+ /**
26
+ * Validate the complete bundled application declaration accepted by Connect.
27
+ *
28
+ * This is the canonical runtime validator used by the server and developer
29
+ * tooling. Keep structural rules in the published JSON Schema and cross-field
30
+ * rules here; consumers must not maintain parallel manifest validators.
31
+ */
32
+ export function validateAppManifest(value, options = {}) {
33
+ const sizeIssue = validateSerializedSize(value, options.maxBytes ?? APPLICATION_MANIFEST_MAX_BYTES);
34
+ if (sizeIssue)
35
+ return invalid([sizeIssue]);
36
+ const candidate = options.allowLocal ? localManifestSchemaCandidate(value) : value;
37
+ const schemaResult = validationResult(appManifestValidator, candidate);
38
+ if (!schemaResult.valid)
39
+ return schemaResult;
40
+ const issues = [
41
+ ...validateManifestOrigins(value, options.allowLocal === true),
42
+ ...validateCapabilityRequirements(value),
43
+ ...validateProvisionRequirements(value)
44
+ ];
45
+ return issues.length === 0 ? { valid: true, issues: [] } : invalid(issues);
46
+ }
47
+ /** Validate, normalize optional sections, and return an isolated declaration. */
48
+ export function parseAppManifest(value, options = {}) {
49
+ const result = validateAppManifest(value, options);
50
+ if (!result.valid)
51
+ throw new AppManifestValidationError(result.issues);
52
+ const manifest = structuredClone(value);
53
+ return {
54
+ ...manifest,
55
+ requirements: manifest.requirements ?? { contracts: [] },
56
+ provisions: manifest.provisions ?? { type_packs: [] },
57
+ notifications: manifest.notifications ?? { criteria: [] }
58
+ };
59
+ }
60
+ export function formatManifestValidationIssues(issues) {
61
+ return issues.map((issue) => `${issue.path || "/"} ${issue.message}`).join("; ");
62
+ }
63
+ function requiredValidator(reference) {
64
+ const validate = ajv.getSchema(reference);
65
+ if (!validate)
66
+ throw new Error(`Canonical schema is unavailable: ${reference}`);
67
+ return validate;
68
+ }
69
+ function validationResult(validate, value) {
70
+ if (validate(value))
71
+ return { valid: true, issues: [] };
72
+ const issues = (validate.errors ?? []).map(validationIssue);
73
+ const specific = issues.filter((issue) => issue.keyword !== "oneOf");
74
+ return invalid(specific.length > 0 ? specific : issues);
75
+ }
76
+ function validationIssue(error) {
77
+ const params = error.params;
78
+ if (error.keyword === "required" && typeof params.missingProperty === "string") {
79
+ return {
80
+ path: joinPointer(error.instancePath, params.missingProperty),
81
+ keyword: error.keyword,
82
+ message: "is required",
83
+ params
84
+ };
85
+ }
86
+ if (error.keyword === "additionalProperties"
87
+ && typeof params.additionalProperty === "string") {
88
+ return {
89
+ path: joinPointer(error.instancePath, params.additionalProperty),
90
+ keyword: error.keyword,
91
+ message: "is not allowed",
92
+ params
93
+ };
94
+ }
95
+ return {
96
+ path: error.instancePath,
97
+ keyword: error.keyword,
98
+ message: error.message ?? "is invalid",
99
+ params
100
+ };
101
+ }
102
+ function joinPointer(parent, child) {
103
+ const escaped = child.replaceAll("~", "~0").replaceAll("/", "~1");
104
+ return `${parent}/${escaped}`;
105
+ }
106
+ function validateSerializedSize(value, maxBytes) {
107
+ const jsonIssue = validateJsonValue(value, "", new WeakSet());
108
+ if (jsonIssue)
109
+ return jsonIssue;
110
+ try {
111
+ const serialized = JSON.stringify(value);
112
+ const bytes = new TextEncoder().encode(serialized).byteLength;
113
+ return bytes <= maxBytes
114
+ ? undefined
115
+ : issue("/", "maxBytes", `must not exceed ${maxBytes} UTF-8 bytes`, { maxBytes, actualBytes: bytes });
116
+ }
117
+ catch {
118
+ return issue("/", "json", "must be serializable as JSON");
119
+ }
120
+ }
121
+ function validateJsonValue(value, path, ancestors) {
122
+ if (value === null
123
+ || typeof value === "string"
124
+ || typeof value === "boolean"
125
+ || (typeof value === "number" && Number.isFinite(value)))
126
+ return undefined;
127
+ if (typeof value !== "object") {
128
+ return issue(path || "/", "json", "must be a JSON value");
129
+ }
130
+ if (ancestors.has(value)) {
131
+ return issue(path || "/", "json", "must not contain circular references");
132
+ }
133
+ const prototype = Object.getPrototypeOf(value);
134
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) {
135
+ return issue(path || "/", "json", "must be a plain JSON object");
136
+ }
137
+ ancestors.add(value);
138
+ const entries = Array.isArray(value)
139
+ ? value.map((item, index) => [String(index), item])
140
+ : Object.entries(value);
141
+ for (const [key, child] of entries) {
142
+ const childIssue = validateJsonValue(child, joinPointer(path, key), ancestors);
143
+ if (childIssue)
144
+ return childIssue;
145
+ }
146
+ ancestors.delete(value);
147
+ return undefined;
148
+ }
149
+ function validateManifestOrigins(value, allowLocal) {
150
+ const manifest = asObject(value);
151
+ if (manifest.distribution === "portable") {
152
+ if (manifest.project_url === undefined) {
153
+ return manifest.icon === undefined
154
+ ? []
155
+ : [issue("/icon", "origin", "requires a project_url on the same origin")];
156
+ }
157
+ try {
158
+ const project = new URL(String(manifest.project_url));
159
+ if (project.protocol !== "https:") {
160
+ return [issue("/project_url", "protocol", "must use HTTPS")];
161
+ }
162
+ if (manifest.icon !== undefined
163
+ && new URL(String(manifest.icon)).origin !== project.origin) {
164
+ return [issue("/icon", "origin", "must use the project_url origin")];
165
+ }
166
+ return [];
167
+ }
168
+ catch {
169
+ return [issue("/", "url", "contains an invalid URL")];
170
+ }
171
+ }
172
+ try {
173
+ const homepage = new URL(String(manifest.homepage));
174
+ if (!secureOrAllowedLocal(homepage, allowLocal)) {
175
+ return [issue("/homepage", "protocol", "must use HTTPS (or loopback HTTP in local mode)")];
176
+ }
177
+ for (const [index, redirect] of manifest.redirect_uris.entries()) {
178
+ const url = new URL(String(redirect));
179
+ if (url.origin === homepage.origin && !secureOrAllowedLocal(url, allowLocal)) {
180
+ return [issue(`/redirect_uris/${index}`, "protocol", "must use HTTPS (or loopback HTTP in local mode)")];
181
+ }
182
+ const nativeAllowed = nativeRedirectMatchesApplication(url, String(manifest.id));
183
+ if (url.origin !== homepage.origin && !nativeAllowed) {
184
+ return [issue(`/redirect_uris/${index}`, "origin", "must use the homepage origin or a private-use scheme matching the application ID")];
185
+ }
186
+ }
187
+ if (manifest.icon !== undefined
188
+ && new URL(String(manifest.icon)).origin !== homepage.origin) {
189
+ return [issue("/icon", "origin", "must use the homepage origin")];
190
+ }
191
+ return [];
192
+ }
193
+ catch {
194
+ return [issue("/", "url", "contains an invalid URL")];
195
+ }
196
+ }
197
+ function nativeRedirectMatchesApplication(url, applicationId) {
198
+ const scheme = url.protocol.slice(0, -1);
199
+ return isNativeRedirectUri(url)
200
+ && (scheme === applicationId || scheme.startsWith(`${applicationId}.`));
201
+ }
202
+ function validateProvisionRequirements(value) {
203
+ const manifest = asObject(value);
204
+ const requirements = asObject(manifest.requirements);
205
+ const requiredContracts = Array.isArray(requirements.contracts)
206
+ ? requirements.contracts
207
+ : [];
208
+ const required = new Set(requiredContracts.map((contract) => {
209
+ const value = asObject(contract);
210
+ return `${value.id}@${value.version}`;
211
+ }));
212
+ const provisions = asObject(manifest.provisions);
213
+ const packs = Array.isArray(provisions.type_packs) ? provisions.type_packs : [];
214
+ const issues = [];
215
+ for (const [packIndex, provisionValue] of packs.entries()) {
216
+ const provision = asObject(provisionValue);
217
+ const providedContracts = Array.isArray(provision.provides)
218
+ ? provision.provides
219
+ : [];
220
+ for (const providedValue of providedContracts) {
221
+ const provided = asObject(providedValue);
222
+ if (!required.has(`${provided.id}@${provided.version}`)) {
223
+ issues.push(issue(`/provisions/type_packs/${packIndex}/provides`, "contractRequirement", "may only contain contracts required by the application"));
224
+ }
225
+ }
226
+ const packManifest = asObject(provision.manifest);
227
+ const declaredResources = Array.isArray(packManifest.resources)
228
+ ? packManifest.resources.map(asObject)
229
+ : [];
230
+ const embeddedResources = Array.isArray(provision.resources)
231
+ ? provision.resources.map(asObject)
232
+ : [];
233
+ const embedded = new Map(embeddedResources.map((resource) => [String(resource.source), resource.document]));
234
+ if (embedded.size !== embeddedResources.length
235
+ || new Set(declaredResources.map((resource) => String(resource.source))).size
236
+ !== declaredResources.length
237
+ || declaredResources.length !== embeddedResources.length) {
238
+ issues.push(issue(`/provisions/type_packs/${packIndex}/resources`, "resourceSet", "must match manifest source paths exactly"));
239
+ continue;
240
+ }
241
+ for (const [resourceIndex, resource] of declaredResources.entries()) {
242
+ const source = String(resource.source);
243
+ const document = embedded.get(source);
244
+ if (typeof document !== "string") {
245
+ issues.push(issue(`/provisions/type_packs/${packIndex}/resources`, "resourceSet", `is missing manifest source ${source}`));
246
+ continue;
247
+ }
248
+ const digest = `sha256:${createHash("sha256").update(document).digest("hex")}`;
249
+ if (digest !== resource.digest) {
250
+ issues.push(issue(`/provisions/type_packs/${packIndex}/manifest/resources/${resourceIndex}/digest`, "digest", "does not match the embedded document"));
251
+ }
252
+ }
253
+ }
254
+ return issues;
255
+ }
256
+ function validateCapabilityRequirements(value) {
257
+ const manifest = asObject(value);
258
+ const requirements = asObject(manifest.requirements);
259
+ const capabilities = asObject(requirements.capabilities);
260
+ if (Object.keys(capabilities).length === 0)
261
+ return [];
262
+ const required = Array.isArray(capabilities.required)
263
+ ? capabilities.required.map(String)
264
+ : [];
265
+ const optional = Array.isArray(capabilities.optional)
266
+ ? capabilities.optional.map(String)
267
+ : [];
268
+ const issues = [];
269
+ const overlap = optional.find((capability) => required.includes(capability));
270
+ if (overlap) {
271
+ issues.push(issue("/requirements/capabilities/optional", "disjoint", `must not repeat required capability ${overlap}`));
272
+ }
273
+ const declared = new Set([...required, ...optional]);
274
+ const provisions = asObject(manifest.provisions);
275
+ if (Array.isArray(requirements.contracts)
276
+ && requirements.contracts.length > 0
277
+ && requirements.access !== "full_collection"
278
+ && !required.includes("definitions.contracts.current")) {
279
+ issues.push(issue("/requirements/capabilities/required", "contractCapability", "must require definitions.contracts.current for contract-scoped requirements"));
280
+ }
281
+ if (declared.has("definitions.type-pack.apply")) {
282
+ if (requirements.access !== "full_collection") {
283
+ issues.push(issue("/requirements/access", "typePackAccess", "must be full_collection for definitions.type-pack.apply"));
284
+ }
285
+ }
286
+ if (Array.isArray(provisions.type_packs)
287
+ && provisions.type_packs.length > 0
288
+ && !required.includes("definitions.type-pack.apply")) {
289
+ issues.push(issue("/requirements/capabilities/required", "typePackCapability", "must require definitions.type-pack.apply when bundled type packs are declared"));
290
+ }
291
+ if (declared.has("notifications.background-delivery")
292
+ && !Array.isArray(asObject(manifest.notifications).criteria)) {
293
+ issues.push(issue("/notifications/criteria", "notificationCapability", "must be declared for notifications.background-delivery"));
294
+ }
295
+ const fileRequirement = asObject(requirements.files);
296
+ const fileActions = Array.isArray(fileRequirement.actions)
297
+ ? new Set(fileRequirement.actions.map(String))
298
+ : new Set();
299
+ for (const action of ["list", "read", "add", "replace", "move", "delete"]) {
300
+ const capability = `files.${action}`;
301
+ if (declared.has(capability) !== fileActions.has(action)) {
302
+ issues.push(issue("/requirements/capabilities", "fileCapability", `${capability} and requirements.files.actions.${action} must be declared together`));
303
+ }
304
+ }
305
+ return issues;
306
+ }
307
+ function localManifestSchemaCandidate(value) {
308
+ const source = asObject(value);
309
+ if (source.distribution === "portable")
310
+ return value;
311
+ // Only URL fields need a schema-safe local substitute. A shallow copy keeps
312
+ // non-JSON values visible to the schema instead of silently dropping them.
313
+ const object = { ...source };
314
+ for (const field of ["homepage", "icon"]) {
315
+ if (typeof object[field] === "string") {
316
+ object[field] = schemaSafeLocalUrl(object[field]);
317
+ }
318
+ }
319
+ if (Array.isArray(object.redirect_uris)) {
320
+ object.redirect_uris = object.redirect_uris.map((url) => typeof url === "string" ? schemaSafeLocalUrl(url) : url);
321
+ }
322
+ return object;
323
+ }
324
+ function schemaSafeLocalUrl(value) {
325
+ try {
326
+ const url = new URL(value);
327
+ if (url.protocol === "http:" && isLoopback(url.hostname))
328
+ url.protocol = "https:";
329
+ return url.href;
330
+ }
331
+ catch {
332
+ return value;
333
+ }
334
+ }
335
+ function secureOrAllowedLocal(url, allowLocal) {
336
+ return url.protocol === "https:"
337
+ || (allowLocal && url.protocol === "http:" && isLoopback(url.hostname));
338
+ }
339
+ function isLoopback(hostname) {
340
+ return hostname === "localhost"
341
+ || hostname === "127.0.0.1"
342
+ || hostname === "[::1]"
343
+ || hostname === "::1";
344
+ }
345
+ function invalid(issues) {
346
+ return { valid: false, issues };
347
+ }
348
+ function issue(path, keyword, message, params = {}) {
349
+ return { path, keyword, message, params };
350
+ }
351
+ function asObject(value) {
352
+ return value !== null && typeof value === "object" && !Array.isArray(value)
353
+ ? value
354
+ : {};
355
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mdbase-dev/connect-protocol",
3
- "version": "0.1.0-beta.28",
3
+ "version": "0.1.0-beta.29",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,15 +16,22 @@
16
16
  "types": "./dist/index.d.ts",
17
17
  "import": "./dist/index.js"
18
18
  },
19
+ "./manifest": {
20
+ "types": "./dist/manifest.d.ts",
21
+ "import": "./dist/manifest.js"
22
+ },
19
23
  "./schemas/*": "./schemas/*"
20
24
  },
21
25
  "files": [
22
26
  "dist",
23
27
  "schemas"
24
28
  ],
25
- "devDependencies": {
29
+ "dependencies": {
26
30
  "ajv": "^8.20.0",
27
- "ajv-formats": "^3.0.1",
31
+ "ajv-formats": "^3.0.1"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^24.0.0",
28
35
  "typescript": "^7.0.2"
29
36
  },
30
37
  "scripts": {
@@ -1904,7 +1904,8 @@
1904
1904
  "code",
1905
1905
  "category",
1906
1906
  "recovery",
1907
- "message"
1907
+ "message",
1908
+ "details"
1908
1909
  ],
1909
1910
  "properties": {
1910
1911
  "problem_version": {
@@ -1933,6 +1934,42 @@
1933
1934
  "trace_id": {
1934
1935
  "type": "string",
1935
1936
  "minLength": 1
1937
+ },
1938
+ "details": {
1939
+ "type": "object",
1940
+ "additionalProperties": false,
1941
+ "required": [
1942
+ "issues"
1943
+ ],
1944
+ "properties": {
1945
+ "issues": {
1946
+ "type": "array",
1947
+ "items": {
1948
+ "type": "object",
1949
+ "additionalProperties": false,
1950
+ "required": [
1951
+ "path",
1952
+ "keyword",
1953
+ "message",
1954
+ "params"
1955
+ ],
1956
+ "properties": {
1957
+ "path": {
1958
+ "type": "string"
1959
+ },
1960
+ "keyword": {
1961
+ "type": "string"
1962
+ },
1963
+ "message": {
1964
+ "type": "string"
1965
+ },
1966
+ "params": {
1967
+ "type": "object"
1968
+ }
1969
+ }
1970
+ }
1971
+ }
1972
+ }
1936
1973
  }
1937
1974
  }
1938
1975
  },
@@ -167,7 +167,28 @@
167
167
  }
168
168
  },
169
169
  "invalid_application_authorization": { "category": "validation", "recovery": "fix_request" },
170
- "invalid_application_manifest": { "category": "validation", "recovery": "fix_request" },
170
+ "invalid_application_manifest": {
171
+ "category": "validation",
172
+ "recovery": "fix_request",
173
+ "details": {
174
+ "required": ["issues"],
175
+ "properties": {
176
+ "issues": {
177
+ "type": "array",
178
+ "items": {
179
+ "type": "object",
180
+ "required": ["path", "keyword", "message", "params"],
181
+ "properties": {
182
+ "path": { "type": "string" },
183
+ "keyword": { "type": "string" },
184
+ "message": { "type": "string" },
185
+ "params": { "type": "object" }
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+ },
171
192
  "invalid_callback": { "category": "validation", "recovery": "fix_request" },
172
193
  "invalid_device_authorization_response": { "category": "integrity", "recovery": "contact_support" },
173
194
  "invalid_encrypted_response": { "category": "integrity", "recovery": "contact_support" },