@mdbase-dev/connect-protocol 0.1.0-beta.100
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/LICENSE +21 -0
- package/dist/application-authorization.d.ts +32 -0
- package/dist/application-authorization.js +207 -0
- package/dist/capabilities.d.ts +73 -0
- package/dist/capabilities.js +92 -0
- package/dist/collection-setup.d.ts +90 -0
- package/dist/collection-setup.js +1 -0
- package/dist/compatibility.d.ts +37 -0
- package/dist/compatibility.js +65 -0
- package/dist/connect-problems.generated.d.ts +1388 -0
- package/dist/connect-problems.generated.js +574 -0
- package/dist/files.d.ts +269 -0
- package/dist/files.js +426 -0
- package/dist/index.d.ts +878 -0
- package/dist/index.js +94 -0
- package/dist/manifest.d.ts +62 -0
- package/dist/manifest.js +527 -0
- package/dist/mutation-fingerprint.d.ts +6 -0
- package/dist/mutation-fingerprint.js +116 -0
- package/dist/operations.d.ts +15 -0
- package/dist/operations.js +194 -0
- package/dist/type-packs.d.ts +133 -0
- package/dist/type-packs.js +30 -0
- package/package.json +42 -0
- package/schemas/application-capability-catalog.v1.json +40 -0
- package/schemas/application-capability-catalog.v2.json +68 -0
- package/schemas/catalog.json +17 -0
- package/schemas/connect-problem.v1.schema.json +5132 -0
- package/schemas/connect-problems.v1.catalog.json +351 -0
- package/schemas/connect-protocol.v1.schema.json +800 -0
- package/schemas/data-contract.schema.json +163 -0
- package/schemas/encrypted-relay.v1.schema.json +63 -0
- package/schemas/encrypted-relay.v2.schema.json +63 -0
- package/schemas/encrypted-relay.v3.schema.json +64 -0
- package/schemas/files.v1.schema.json +507 -0
- package/schemas/interop/v0.1/action-cancellation.schema.json +6 -0
- package/schemas/interop/v0.1/action-invocation.schema.json +6 -0
- package/schemas/interop/v0.1/action-outcome.schema.json +6 -0
- package/schemas/interop/v0.1/action-provider-declaration.schema.json +6 -0
- package/schemas/interop/v0.1/action-request.schema.json +6 -0
- package/schemas/interop/v0.1/conformance-claim.schema.json +6 -0
- package/schemas/interop/v0.1/event-source-declaration.schema.json +6 -0
- package/schemas/interop/v0.1/event.schema.json +6 -0
- package/schemas/interop/v0.1/profile.schema.json +456 -0
- package/schemas/mdbase-app.legacy-v1.schema.json +405 -0
- package/schemas/mdbase-app.schema.json +381 -0
- package/schemas/mdbase-query.v0.3.schema.json +171 -0
- package/schemas/notification-webhook.v1.schema.json +56 -0
- package/schemas/operation-catalog.v1.json +42 -0
- package/schemas/operation-mutation-classification.v1.json +537 -0
- package/schemas/sync.v1.schema.json +483 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { FRESH_APPLICATION_AUTHORIZATION_CAPABILITIES } from "./capabilities.js";
|
|
2
|
+
export * from "./connect-problems.generated.js";
|
|
3
|
+
export * from "./files.js";
|
|
4
|
+
export * from "./operations.js";
|
|
5
|
+
export * from "./mutation-fingerprint.js";
|
|
6
|
+
export * from "./compatibility.js";
|
|
7
|
+
export * from "./capabilities.js";
|
|
8
|
+
export * from "./application-authorization.js";
|
|
9
|
+
export * from "./type-packs.js";
|
|
10
|
+
export * from "./collection-setup.js";
|
|
11
|
+
export const CONTROL_PROTOCOL_VERSION = 1;
|
|
12
|
+
export { AUTHORIZATION_BINDING_PROTOCOL_VERSION as APPLICATION_AUTHORIZATION_PROTOCOL_VERSION } from "./compatibility.js";
|
|
13
|
+
export const LOOPBACK_PROTOCOL_VERSION = 1;
|
|
14
|
+
export const DEFAULT_LOOPBACK_PORT = 28_485;
|
|
15
|
+
export const RELAY_ENCRYPTION_SUITE = "P256-HKDF-SHA256-AES256GCM";
|
|
16
|
+
export const GRANT_ENCRYPTION_PROTOCOL_VERSION = 1;
|
|
17
|
+
export const SYNC_PROTOCOL_VERSION = 1;
|
|
18
|
+
export const CONTRACT_SETUP_CAPABILITY = "contract-setup-v1";
|
|
19
|
+
export const FILE_RELAY_CAPABILITY = "file-relay-v1";
|
|
20
|
+
export const PROTOCOL_USAGE_REPORT_CAPABILITY = "protocol-usage-report-v1";
|
|
21
|
+
export const POLICY_FRESHNESS_LEASE_CAPABILITY = "policy-freshness-lease-v1";
|
|
22
|
+
/** Advertised beta capabilities are intentionally broader than the beta baseline. */
|
|
23
|
+
export const RELAY_REQUIRED_CAPABILITIES = [
|
|
24
|
+
"application-authorization-v4",
|
|
25
|
+
"authorization-activation",
|
|
26
|
+
"encrypted-relay",
|
|
27
|
+
"policy-ack"
|
|
28
|
+
];
|
|
29
|
+
export const MINIMUM_CONNECTOR_VERSION = "0.1.0-beta.33";
|
|
30
|
+
export const POLICY_FRESHNESS_LEASE_MINIMUM_CONNECTOR_VERSION = "0.1.0-beta.91";
|
|
31
|
+
export const HOSTED_PROVIDER_REQUIRED_CAPABILITIES = [
|
|
32
|
+
"durable-mutation-journal-v1",
|
|
33
|
+
"durable-file-lifecycle-v1"
|
|
34
|
+
];
|
|
35
|
+
export const HOSTED_CANDIDATE_B_ACTIVATION_CAPABILITY = "candidate-b-activation-v1";
|
|
36
|
+
export const HOSTED_PROVIDER_CAPABILITIES = [
|
|
37
|
+
...FRESH_APPLICATION_AUTHORIZATION_CAPABILITIES,
|
|
38
|
+
...HOSTED_PROVIDER_REQUIRED_CAPABILITIES,
|
|
39
|
+
"mutation-replay-after-credential-retirement-v1",
|
|
40
|
+
HOSTED_CANDIDATE_B_ACTIVATION_CAPABILITY
|
|
41
|
+
];
|
|
42
|
+
export const APPLICATION_DECLARATION_EVIDENCE_CAPABILITY = "application-declaration-evidence-v1";
|
|
43
|
+
export const RELAY_CAPABILITIES = [
|
|
44
|
+
...FRESH_APPLICATION_AUTHORIZATION_CAPABILITIES,
|
|
45
|
+
APPLICATION_DECLARATION_EVIDENCE_CAPABILITY,
|
|
46
|
+
...RELAY_REQUIRED_CAPABILITIES,
|
|
47
|
+
POLICY_FRESHNESS_LEASE_CAPABILITY,
|
|
48
|
+
"application-authorization-v5",
|
|
49
|
+
CONTRACT_SETUP_CAPABILITY,
|
|
50
|
+
FILE_RELAY_CAPABILITY,
|
|
51
|
+
PROTOCOL_USAGE_REPORT_CAPABILITY
|
|
52
|
+
];
|
|
53
|
+
export const AUTHORITY_PROOF_VERSION = 1;
|
|
54
|
+
export const AUTHORITY_PROOF_ALGORITHM = "P256-SHA256";
|
|
55
|
+
export const AUTHORITY_PROOF_DOMAIN = "mdbase-authority-request-proof-v1";
|
|
56
|
+
export const AUTHORITY_PROOF_HEADERS = {
|
|
57
|
+
version: "x-mdbase-proof-version",
|
|
58
|
+
timestamp: "x-mdbase-proof-timestamp",
|
|
59
|
+
nonce: "x-mdbase-proof-nonce",
|
|
60
|
+
signature: "x-mdbase-proof-signature"
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Validate a native OAuth callback against the publisher of its web manifest.
|
|
64
|
+
* Reverse-domain private-use schemes keep native app identity tied to the
|
|
65
|
+
* manifest origin; PKCE remains mandatory at the authorization boundary.
|
|
66
|
+
*/
|
|
67
|
+
export function isNativeRedirectUri(url, publisherHostname) {
|
|
68
|
+
const scheme = url.protocol.slice(0, -1);
|
|
69
|
+
const publisherPrefix = publisherHostname
|
|
70
|
+
?.toLowerCase()
|
|
71
|
+
.split(".")
|
|
72
|
+
.reverse()
|
|
73
|
+
.join(".");
|
|
74
|
+
return scheme.includes(".")
|
|
75
|
+
&& /^[a-z][a-z0-9+.-]*$/.test(scheme)
|
|
76
|
+
&& !["http", "https", "file", "javascript", "data"].includes(scheme)
|
|
77
|
+
&& (!publisherPrefix
|
|
78
|
+
|| scheme === publisherPrefix
|
|
79
|
+
|| scheme.startsWith(`${publisherPrefix}.`))
|
|
80
|
+
&& url.username === ""
|
|
81
|
+
&& url.password === ""
|
|
82
|
+
&& url.hostname.length > 0
|
|
83
|
+
&& url.hash === "";
|
|
84
|
+
}
|
|
85
|
+
export const CONNECT_SCHEMA_IDS = {
|
|
86
|
+
appManifest: "https://mdbase.dev/connect/schemas/mdbase-app.v1.json",
|
|
87
|
+
notificationWebhook: "https://mdbase.dev/connect/schemas/notification-webhook.v1.json",
|
|
88
|
+
dataContract: "https://mdbase.dev/schemas/v0.3/data-contract.schema.json",
|
|
89
|
+
eventActionInterop: "https://mdbase.dev/schemas/interop/v0.1/profile.schema.json",
|
|
90
|
+
protocol: "https://mdbase.dev/connect/schemas/connect-protocol.v1.json",
|
|
91
|
+
encryptedRelay: "https://mdbase.dev/connect/schemas/encrypted-relay.v3.json",
|
|
92
|
+
files: "https://mdbase.dev/connect/schemas/files.v1.json",
|
|
93
|
+
sync: "https://mdbase.dev/connect/schemas/sync.v1.json"
|
|
94
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { LegacyMdbaseAppManifest } from "./index.js";
|
|
2
|
+
export type { LegacyApplicationFileRequirement, LegacyApplicationRequirements, LegacyMdbaseWebAppManifest, LegacyMdbasePortableAppManifest, LegacyMdbaseAppManifest } from "./index.js";
|
|
3
|
+
import { type ApplicationNotifications, type ApplicationProvisions, type ApplicationRequirements, type MdbasePortableAppManifest, type MdbaseWebAppManifest } from "./index.js";
|
|
4
|
+
export declare const APPLICATION_MANIFEST_MAX_BYTES = 524288;
|
|
5
|
+
export interface ManifestValidationIssue {
|
|
6
|
+
path: string;
|
|
7
|
+
keyword: string;
|
|
8
|
+
message: string;
|
|
9
|
+
params: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
export type ManifestValidationResult = {
|
|
12
|
+
valid: true;
|
|
13
|
+
issues: [];
|
|
14
|
+
} | {
|
|
15
|
+
valid: false;
|
|
16
|
+
issues: ManifestValidationIssue[];
|
|
17
|
+
};
|
|
18
|
+
export interface ManifestValidationOptions {
|
|
19
|
+
/** Allow HTTP only for localhost/loopback developer web manifests. */
|
|
20
|
+
allowLocal?: boolean;
|
|
21
|
+
/** Maximum UTF-8 size of the complete declaration. */
|
|
22
|
+
maxBytes?: number;
|
|
23
|
+
}
|
|
24
|
+
interface ValidatedManifestFields {
|
|
25
|
+
requirements: ApplicationRequirements;
|
|
26
|
+
provisions: ApplicationProvisions;
|
|
27
|
+
notifications: ApplicationNotifications;
|
|
28
|
+
}
|
|
29
|
+
export type ValidatedWebAppManifest = Omit<MdbaseWebAppManifest, keyof ValidatedManifestFields> & ValidatedManifestFields;
|
|
30
|
+
export type ValidatedPortableAppManifest = Omit<MdbasePortableAppManifest, keyof ValidatedManifestFields> & ValidatedManifestFields;
|
|
31
|
+
export type ValidatedAppManifest = ValidatedWebAppManifest | ValidatedPortableAppManifest;
|
|
32
|
+
export type ValidatedLegacyAppManifest = LegacyMdbaseAppManifest & {
|
|
33
|
+
provisions: ApplicationProvisions;
|
|
34
|
+
notifications: ApplicationNotifications;
|
|
35
|
+
};
|
|
36
|
+
/** The discriminator describes the original declaration, not a translation. */
|
|
37
|
+
export type VersionedAppManifest = {
|
|
38
|
+
contractVersion: 1;
|
|
39
|
+
manifest: ValidatedLegacyAppManifest;
|
|
40
|
+
} | {
|
|
41
|
+
contractVersion: 2;
|
|
42
|
+
manifest: ValidatedAppManifest;
|
|
43
|
+
};
|
|
44
|
+
export declare class AppManifestValidationError extends Error {
|
|
45
|
+
readonly issues: ManifestValidationIssue[];
|
|
46
|
+
constructor(issues: ManifestValidationIssue[]);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Validate the complete bundled application declaration accepted by Connect.
|
|
50
|
+
*
|
|
51
|
+
* This is the canonical runtime validator used by the server and developer
|
|
52
|
+
* tooling. Keep structural rules in the published JSON Schema and cross-field
|
|
53
|
+
* rules here; consumers must not maintain parallel manifest validators.
|
|
54
|
+
*/
|
|
55
|
+
export declare function validateAppManifest(value: unknown, options?: ManifestValidationOptions): ManifestValidationResult;
|
|
56
|
+
/** Explicit opt-in to predecessor declarations; omission defaults to v1. */
|
|
57
|
+
export declare function validateVersionedAppManifest(value: unknown, options?: ManifestValidationOptions): ManifestValidationResult;
|
|
58
|
+
/** Validate, normalize optional sections, and return an isolated declaration. */
|
|
59
|
+
export declare function parseAppManifest(value: unknown, options?: ManifestValidationOptions): ValidatedAppManifest;
|
|
60
|
+
/** Validate and clone without changing capability IDs, file aliases, or version. */
|
|
61
|
+
export declare function parseVersionedAppManifest(value: unknown, options?: ManifestValidationOptions): VersionedAppManifest;
|
|
62
|
+
export declare function formatManifestValidationIssues(issues: readonly ManifestValidationIssue[]): string;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
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 legacyAppManifestSchema from "../schemas/mdbase-app.legacy-v1.schema.json" with { type: "json" };
|
|
6
|
+
import { isNativeRedirectUri } from "./index.js";
|
|
7
|
+
export const APPLICATION_MANIFEST_MAX_BYTES = 524_288;
|
|
8
|
+
export class AppManifestValidationError extends Error {
|
|
9
|
+
issues;
|
|
10
|
+
constructor(issues) {
|
|
11
|
+
super(`Invalid application manifest: ${formatManifestValidationIssues(issues)}`);
|
|
12
|
+
this.issues = issues;
|
|
13
|
+
this.name = "AppManifestValidationError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const ajv = new Ajv2020({
|
|
17
|
+
allErrors: true,
|
|
18
|
+
strict: true,
|
|
19
|
+
// `required` may name properties declared by an enclosing oneOf branch.
|
|
20
|
+
strictRequired: false
|
|
21
|
+
});
|
|
22
|
+
const addFormats = addFormatsModule;
|
|
23
|
+
addFormats(ajv);
|
|
24
|
+
ajv.addSchema(appManifestSchema);
|
|
25
|
+
const appManifestValidator = requiredValidator(String(appManifestSchema.$id));
|
|
26
|
+
ajv.addSchema(legacyAppManifestSchema);
|
|
27
|
+
const legacyAppManifestValidator = requiredValidator(String(legacyAppManifestSchema.$id));
|
|
28
|
+
/**
|
|
29
|
+
* Validate the complete bundled application declaration accepted by Connect.
|
|
30
|
+
*
|
|
31
|
+
* This is the canonical runtime validator used by the server and developer
|
|
32
|
+
* tooling. Keep structural rules in the published JSON Schema and cross-field
|
|
33
|
+
* rules here; consumers must not maintain parallel manifest validators.
|
|
34
|
+
*/
|
|
35
|
+
export function validateAppManifest(value, options = {}) {
|
|
36
|
+
return validateManifest(value, options, false);
|
|
37
|
+
}
|
|
38
|
+
/** Explicit opt-in to predecessor declarations; omission defaults to v1. */
|
|
39
|
+
export function validateVersionedAppManifest(value, options = {}) {
|
|
40
|
+
return validateManifest(value, options, true);
|
|
41
|
+
}
|
|
42
|
+
function declaredContractVersion(value) {
|
|
43
|
+
const requirements = asObject(asObject(value).requirements);
|
|
44
|
+
return requirements.capabilities === undefined
|
|
45
|
+
? 1
|
|
46
|
+
: asObject(requirements.capabilities).contract_version;
|
|
47
|
+
}
|
|
48
|
+
function validateManifest(value, options, versioned) {
|
|
49
|
+
const sizeIssue = validateSerializedSize(value, options.maxBytes ?? APPLICATION_MANIFEST_MAX_BYTES);
|
|
50
|
+
if (sizeIssue)
|
|
51
|
+
return invalid([sizeIssue]);
|
|
52
|
+
const version = versioned ? declaredContractVersion(value) : 2;
|
|
53
|
+
if (version !== 1 && version !== 2) {
|
|
54
|
+
return invalid([issue("/requirements/capabilities/contract_version", "contractVersion", "must declare supported contract version 1 or 2")]);
|
|
55
|
+
}
|
|
56
|
+
const candidate = options.allowLocal ? localManifestSchemaCandidate(value) : value;
|
|
57
|
+
const schemaResult = validationResult(version === 1 ? legacyAppManifestValidator : appManifestValidator, candidate);
|
|
58
|
+
if (!schemaResult.valid)
|
|
59
|
+
return schemaResult;
|
|
60
|
+
const issues = [
|
|
61
|
+
...validateManifestOrigins(value, options.allowLocal === true),
|
|
62
|
+
...(version === 1
|
|
63
|
+
? validateLegacyCapabilityRequirements(value)
|
|
64
|
+
: validateCapabilityRequirements(value)),
|
|
65
|
+
...validateProvisionRequirements(value),
|
|
66
|
+
...validateConfigurationSetup(value)
|
|
67
|
+
];
|
|
68
|
+
return issues.length === 0 ? { valid: true, issues: [] } : invalid(issues);
|
|
69
|
+
}
|
|
70
|
+
/** Validate, normalize optional sections, and return an isolated declaration. */
|
|
71
|
+
export function parseAppManifest(value, options = {}) {
|
|
72
|
+
const result = validateAppManifest(value, options);
|
|
73
|
+
if (!result.valid)
|
|
74
|
+
throw new AppManifestValidationError(result.issues);
|
|
75
|
+
return normalizeManifest(value);
|
|
76
|
+
}
|
|
77
|
+
/** Validate and clone without changing capability IDs, file aliases, or version. */
|
|
78
|
+
export function parseVersionedAppManifest(value, options = {}) {
|
|
79
|
+
const result = validateVersionedAppManifest(value, options);
|
|
80
|
+
if (!result.valid)
|
|
81
|
+
throw new AppManifestValidationError(result.issues);
|
|
82
|
+
return declaredContractVersion(value) === 1
|
|
83
|
+
? { contractVersion: 1, manifest: normalizeManifest(value) }
|
|
84
|
+
: { contractVersion: 2, manifest: normalizeManifest(value) };
|
|
85
|
+
}
|
|
86
|
+
function normalizeManifest(value) {
|
|
87
|
+
const manifest = structuredClone(value);
|
|
88
|
+
return {
|
|
89
|
+
...manifest,
|
|
90
|
+
requirements: {
|
|
91
|
+
configuration: [],
|
|
92
|
+
...manifest.requirements
|
|
93
|
+
},
|
|
94
|
+
provisions: {
|
|
95
|
+
type_packs: [],
|
|
96
|
+
configuration: [],
|
|
97
|
+
...manifest.provisions
|
|
98
|
+
},
|
|
99
|
+
notifications: manifest.notifications ?? { criteria: [] }
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export function formatManifestValidationIssues(issues) {
|
|
103
|
+
return issues.map((issue) => `${issue.path || "/"} ${issue.message}`).join("; ");
|
|
104
|
+
}
|
|
105
|
+
function requiredValidator(reference) {
|
|
106
|
+
const validate = ajv.getSchema(reference);
|
|
107
|
+
if (!validate)
|
|
108
|
+
throw new Error(`Canonical schema is unavailable: ${reference}`);
|
|
109
|
+
return validate;
|
|
110
|
+
}
|
|
111
|
+
function validationResult(validate, value) {
|
|
112
|
+
if (validate(value))
|
|
113
|
+
return { valid: true, issues: [] };
|
|
114
|
+
const issues = (validate.errors ?? []).map(validationIssue);
|
|
115
|
+
const specific = issues.filter((issue) => issue.keyword !== "oneOf");
|
|
116
|
+
return invalid(specific.length > 0 ? specific : issues);
|
|
117
|
+
}
|
|
118
|
+
function validationIssue(error) {
|
|
119
|
+
const params = error.params;
|
|
120
|
+
if (error.keyword === "required" && typeof params.missingProperty === "string") {
|
|
121
|
+
return {
|
|
122
|
+
path: joinPointer(error.instancePath, params.missingProperty),
|
|
123
|
+
keyword: error.keyword,
|
|
124
|
+
message: "is required",
|
|
125
|
+
params
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (error.keyword === "additionalProperties"
|
|
129
|
+
&& typeof params.additionalProperty === "string") {
|
|
130
|
+
return {
|
|
131
|
+
path: joinPointer(error.instancePath, params.additionalProperty),
|
|
132
|
+
keyword: error.keyword,
|
|
133
|
+
message: "is not allowed",
|
|
134
|
+
params
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
path: error.instancePath,
|
|
139
|
+
keyword: error.keyword,
|
|
140
|
+
message: error.message ?? "is invalid",
|
|
141
|
+
params
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function joinPointer(parent, child) {
|
|
145
|
+
const escaped = child.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
146
|
+
return `${parent}/${escaped}`;
|
|
147
|
+
}
|
|
148
|
+
function validateSerializedSize(value, maxBytes) {
|
|
149
|
+
const jsonIssue = validateJsonValue(value, "", new WeakSet());
|
|
150
|
+
if (jsonIssue)
|
|
151
|
+
return jsonIssue;
|
|
152
|
+
try {
|
|
153
|
+
const serialized = JSON.stringify(value);
|
|
154
|
+
const bytes = new TextEncoder().encode(serialized).byteLength;
|
|
155
|
+
return bytes <= maxBytes
|
|
156
|
+
? undefined
|
|
157
|
+
: issue("/", "maxBytes", `must not exceed ${maxBytes} UTF-8 bytes`, { maxBytes, actualBytes: bytes });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return issue("/", "json", "must be serializable as JSON");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function validateJsonValue(value, path, ancestors) {
|
|
164
|
+
if (value === null
|
|
165
|
+
|| typeof value === "string"
|
|
166
|
+
|| typeof value === "boolean"
|
|
167
|
+
|| (typeof value === "number" && Number.isFinite(value)))
|
|
168
|
+
return undefined;
|
|
169
|
+
if (typeof value !== "object") {
|
|
170
|
+
return issue(path || "/", "json", "must be a JSON value");
|
|
171
|
+
}
|
|
172
|
+
if (ancestors.has(value)) {
|
|
173
|
+
return issue(path || "/", "json", "must not contain circular references");
|
|
174
|
+
}
|
|
175
|
+
const prototype = Object.getPrototypeOf(value);
|
|
176
|
+
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) {
|
|
177
|
+
return issue(path || "/", "json", "must be a plain JSON object");
|
|
178
|
+
}
|
|
179
|
+
ancestors.add(value);
|
|
180
|
+
const entries = Array.isArray(value)
|
|
181
|
+
? value.map((item, index) => [String(index), item])
|
|
182
|
+
: Object.entries(value);
|
|
183
|
+
for (const [key, child] of entries) {
|
|
184
|
+
const childIssue = validateJsonValue(child, joinPointer(path, key), ancestors);
|
|
185
|
+
if (childIssue)
|
|
186
|
+
return childIssue;
|
|
187
|
+
}
|
|
188
|
+
ancestors.delete(value);
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
function validateManifestOrigins(value, allowLocal) {
|
|
192
|
+
const manifest = asObject(value);
|
|
193
|
+
if (manifest.distribution === "portable") {
|
|
194
|
+
if (manifest.project_url === undefined) {
|
|
195
|
+
return manifest.icon === undefined
|
|
196
|
+
? []
|
|
197
|
+
: [issue("/icon", "origin", "requires a project_url on the same origin")];
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const project = new URL(String(manifest.project_url));
|
|
201
|
+
if (project.protocol !== "https:") {
|
|
202
|
+
return [issue("/project_url", "protocol", "must use HTTPS")];
|
|
203
|
+
}
|
|
204
|
+
if (manifest.icon !== undefined
|
|
205
|
+
&& new URL(String(manifest.icon)).origin !== project.origin) {
|
|
206
|
+
return [issue("/icon", "origin", "must use the project_url origin")];
|
|
207
|
+
}
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return [issue("/", "url", "contains an invalid URL")];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const homepage = new URL(String(manifest.homepage));
|
|
216
|
+
if (!secureOrAllowedLocal(homepage, allowLocal)) {
|
|
217
|
+
return [issue("/homepage", "protocol", "must use HTTPS (or loopback HTTP in local mode)")];
|
|
218
|
+
}
|
|
219
|
+
for (const [index, redirect] of manifest.redirect_uris.entries()) {
|
|
220
|
+
const url = new URL(String(redirect));
|
|
221
|
+
if (url.origin === homepage.origin && !secureOrAllowedLocal(url, allowLocal)) {
|
|
222
|
+
return [issue(`/redirect_uris/${index}`, "protocol", "must use HTTPS (or loopback HTTP in local mode)")];
|
|
223
|
+
}
|
|
224
|
+
const nativeAllowed = nativeRedirectMatchesApplication(url, String(manifest.id));
|
|
225
|
+
if (url.origin !== homepage.origin && !nativeAllowed) {
|
|
226
|
+
return [issue(`/redirect_uris/${index}`, "origin", "must use the homepage origin or a private-use scheme matching the application ID")];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (manifest.icon !== undefined
|
|
230
|
+
&& new URL(String(manifest.icon)).origin !== homepage.origin) {
|
|
231
|
+
return [issue("/icon", "origin", "must use the homepage origin")];
|
|
232
|
+
}
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return [issue("/", "url", "contains an invalid URL")];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function nativeRedirectMatchesApplication(url, applicationId) {
|
|
240
|
+
const scheme = url.protocol.slice(0, -1);
|
|
241
|
+
return isNativeRedirectUri(url)
|
|
242
|
+
&& (scheme === applicationId || scheme.startsWith(`${applicationId}.`));
|
|
243
|
+
}
|
|
244
|
+
function validateProvisionRequirements(value) {
|
|
245
|
+
const manifest = asObject(value);
|
|
246
|
+
const requirements = asObject(manifest.requirements);
|
|
247
|
+
const requiredContracts = Array.isArray(requirements.contracts)
|
|
248
|
+
? requirements.contracts
|
|
249
|
+
: [];
|
|
250
|
+
const required = new Set(requiredContracts.map((contract) => {
|
|
251
|
+
const value = asObject(contract);
|
|
252
|
+
return `${value.id}@${value.version}`;
|
|
253
|
+
}));
|
|
254
|
+
const provisions = asObject(manifest.provisions);
|
|
255
|
+
const packs = Array.isArray(provisions.type_packs) ? provisions.type_packs : [];
|
|
256
|
+
const issues = [];
|
|
257
|
+
for (const [packIndex, provisionValue] of packs.entries()) {
|
|
258
|
+
const provision = asObject(provisionValue);
|
|
259
|
+
const providedContracts = Array.isArray(provision.provides)
|
|
260
|
+
? provision.provides
|
|
261
|
+
: [];
|
|
262
|
+
for (const providedValue of providedContracts) {
|
|
263
|
+
const provided = asObject(providedValue);
|
|
264
|
+
if (!required.has(`${provided.id}@${provided.version}`)) {
|
|
265
|
+
issues.push(issue(`/provisions/type_packs/${packIndex}/provides`, "contractRequirement", "may only contain contracts required by the application"));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const packManifest = asObject(provision.manifest);
|
|
269
|
+
const declaredResources = Array.isArray(packManifest.resources)
|
|
270
|
+
? packManifest.resources.map(asObject)
|
|
271
|
+
: [];
|
|
272
|
+
const embeddedResources = Array.isArray(provision.resources)
|
|
273
|
+
? provision.resources.map(asObject)
|
|
274
|
+
: [];
|
|
275
|
+
const embedded = new Map(embeddedResources.map((resource) => [String(resource.source), resource.document]));
|
|
276
|
+
if (embedded.size !== embeddedResources.length
|
|
277
|
+
|| new Set(declaredResources.map((resource) => String(resource.source))).size
|
|
278
|
+
!== declaredResources.length
|
|
279
|
+
|| declaredResources.length !== embeddedResources.length) {
|
|
280
|
+
issues.push(issue(`/provisions/type_packs/${packIndex}/resources`, "resourceSet", "must match manifest source paths exactly"));
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
for (const [resourceIndex, resource] of declaredResources.entries()) {
|
|
284
|
+
const source = String(resource.source);
|
|
285
|
+
const document = embedded.get(source);
|
|
286
|
+
if (typeof document !== "string") {
|
|
287
|
+
issues.push(issue(`/provisions/type_packs/${packIndex}/resources`, "resourceSet", `is missing manifest source ${source}`));
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const digest = `sha256:${createHash("sha256").update(document).digest("hex")}`;
|
|
291
|
+
if (digest !== resource.digest) {
|
|
292
|
+
issues.push(issue(`/provisions/type_packs/${packIndex}/manifest/resources/${resourceIndex}/digest`, "digest", "does not match the embedded document"));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return issues;
|
|
297
|
+
}
|
|
298
|
+
function validateConfigurationSetup(value) {
|
|
299
|
+
const manifest = asObject(value);
|
|
300
|
+
const requirements = asObject(manifest.requirements);
|
|
301
|
+
const provisions = asObject(manifest.provisions);
|
|
302
|
+
const requiredValues = Array.isArray(requirements.configuration)
|
|
303
|
+
? requirements.configuration
|
|
304
|
+
: [];
|
|
305
|
+
const provisionValues = Array.isArray(provisions.configuration)
|
|
306
|
+
? provisions.configuration
|
|
307
|
+
: [];
|
|
308
|
+
const issues = [];
|
|
309
|
+
const required = new Map();
|
|
310
|
+
for (const [index, candidate] of requiredValues.entries()) {
|
|
311
|
+
const requirement = asObject(candidate);
|
|
312
|
+
const id = String(requirement.id);
|
|
313
|
+
const path = String(requirement.path);
|
|
314
|
+
const pathError = configurationPointerError(path);
|
|
315
|
+
if (pathError) {
|
|
316
|
+
issues.push(issue(`/requirements/configuration/${index}/path`, "configurationPointer", pathError));
|
|
317
|
+
}
|
|
318
|
+
if (required.has(id)) {
|
|
319
|
+
issues.push(issue(`/requirements/configuration/${index}/id`, "uniqueRequirement", `duplicates configuration requirement ${id}`));
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
required.set(id, { path, value: requirement.value, index });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const linked = new Set();
|
|
326
|
+
const contributionKeys = new Set();
|
|
327
|
+
for (const [index, candidate] of provisionValues.entries()) {
|
|
328
|
+
const provision = asObject(candidate);
|
|
329
|
+
const requirementId = String(provision.requirement);
|
|
330
|
+
const path = String(provision.path);
|
|
331
|
+
const pathError = configurationPointerError(path);
|
|
332
|
+
if (pathError) {
|
|
333
|
+
issues.push(issue(`/provisions/configuration/${index}/path`, "configurationPointer", pathError));
|
|
334
|
+
}
|
|
335
|
+
const requirement = required.get(requirementId);
|
|
336
|
+
if (!requirement) {
|
|
337
|
+
issues.push(issue(`/provisions/configuration/${index}/requirement`, "configurationRequirement", `references unknown configuration requirement ${requirementId}`));
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (linked.has(requirementId)) {
|
|
341
|
+
issues.push(issue(`/provisions/configuration/${index}/requirement`, "uniqueProvision", `configuration requirement ${requirementId} may have exactly one provision`));
|
|
342
|
+
}
|
|
343
|
+
linked.add(requirementId);
|
|
344
|
+
if (path !== requirement.path) {
|
|
345
|
+
issues.push(issue(`/provisions/configuration/${index}/path`, "configurationRequirement", `must equal /requirements/configuration/${requirement.index}/path`));
|
|
346
|
+
}
|
|
347
|
+
if (JSON.stringify(provision.value) !== JSON.stringify(requirement.value)) {
|
|
348
|
+
issues.push(issue(`/provisions/configuration/${index}/value`, "configurationRequirement", `must equal /requirements/configuration/${requirement.index}/value`));
|
|
349
|
+
}
|
|
350
|
+
const contributionKey = `${path}\0${JSON.stringify(provision.value)}`;
|
|
351
|
+
if (contributionKeys.has(contributionKey)) {
|
|
352
|
+
issues.push(issue(`/provisions/configuration/${index}`, "uniqueContribution", "duplicates another configuration path and value contribution"));
|
|
353
|
+
}
|
|
354
|
+
contributionKeys.add(contributionKey);
|
|
355
|
+
}
|
|
356
|
+
for (const [id, requirement] of required) {
|
|
357
|
+
if (!linked.has(id)) {
|
|
358
|
+
issues.push(issue(`/requirements/configuration/${requirement.index}`, "configurationProvision", `requires one provision linked by id ${id}`));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return issues;
|
|
362
|
+
}
|
|
363
|
+
function configurationPointerError(path) {
|
|
364
|
+
if (!path.startsWith("/") || new TextEncoder().encode(path).byteLength > 1024) {
|
|
365
|
+
return "must be a bounded RFC 6901 JSON pointer";
|
|
366
|
+
}
|
|
367
|
+
const encoded = path.slice(1).split("/");
|
|
368
|
+
if (encoded.length < 2 || encoded.length > 16) {
|
|
369
|
+
return "must address a value below an x-* extension namespace";
|
|
370
|
+
}
|
|
371
|
+
const segments = [];
|
|
372
|
+
for (const value of encoded) {
|
|
373
|
+
if (/~(?:[^01]|$)/u.test(value))
|
|
374
|
+
return "contains an invalid RFC 6901 escape";
|
|
375
|
+
const segment = value.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
376
|
+
if (segment.length === 0
|
|
377
|
+
|| segment === "-"
|
|
378
|
+
|| /^\d+$/u.test(segment)
|
|
379
|
+
|| [...segment].some((character) => /\p{Cc}/u.test(character)))
|
|
380
|
+
return "contains a disallowed object-key segment";
|
|
381
|
+
segments.push(segment);
|
|
382
|
+
}
|
|
383
|
+
return /^x-[a-z0-9][a-z0-9-]*$/u.test(segments[0])
|
|
384
|
+
? undefined
|
|
385
|
+
: "must be inside a top-level x-* extension namespace";
|
|
386
|
+
}
|
|
387
|
+
function validateCapabilityRequirements(value) {
|
|
388
|
+
const manifest = asObject(value);
|
|
389
|
+
const requirements = asObject(manifest.requirements);
|
|
390
|
+
const capabilities = asObject(requirements.capabilities);
|
|
391
|
+
const issues = [];
|
|
392
|
+
if (requirements.access !== "full_collection") {
|
|
393
|
+
issues.push(issue("/requirements/access", "collectionAccess", "must explicitly request full_collection; legacy or omitted access is not widened"));
|
|
394
|
+
}
|
|
395
|
+
const required = Array.isArray(capabilities.required)
|
|
396
|
+
? capabilities.required.map(String)
|
|
397
|
+
: [];
|
|
398
|
+
const optional = Array.isArray(capabilities.optional)
|
|
399
|
+
? capabilities.optional.map(String)
|
|
400
|
+
: [];
|
|
401
|
+
const overlap = optional.find((capability) => required.includes(capability));
|
|
402
|
+
if (overlap) {
|
|
403
|
+
issues.push(issue("/requirements/capabilities/optional", "disjoint", `must not repeat required capability ${overlap}`));
|
|
404
|
+
}
|
|
405
|
+
const files = asObject(requirements.files);
|
|
406
|
+
const requiredFiles = Array.isArray(files.required) ? files.required.map(String) : [];
|
|
407
|
+
const optionalFiles = Array.isArray(files.optional) ? files.optional.map(String) : [];
|
|
408
|
+
const fileOverlap = optionalFiles.find((action) => requiredFiles.includes(action));
|
|
409
|
+
if (fileOverlap) {
|
|
410
|
+
issues.push(issue("/requirements/files/optional", "disjoint", `must not repeat required file action ${fileOverlap}`));
|
|
411
|
+
}
|
|
412
|
+
return issues;
|
|
413
|
+
}
|
|
414
|
+
// Frozen cross-field rules from c2596a6e; notably, absent capabilities skip
|
|
415
|
+
// alias/setup checks rather than inventing or widening a declaration.
|
|
416
|
+
function validateLegacyCapabilityRequirements(value) {
|
|
417
|
+
const manifest = asObject(value);
|
|
418
|
+
const requirements = asObject(manifest.requirements);
|
|
419
|
+
const capabilities = asObject(requirements.capabilities);
|
|
420
|
+
const issues = [];
|
|
421
|
+
if (requirements.access !== "full_collection") {
|
|
422
|
+
issues.push(issue("/requirements/access", "collectionAccess", "must explicitly request full_collection; legacy or omitted access is not widened"));
|
|
423
|
+
}
|
|
424
|
+
if (Object.keys(capabilities).length === 0)
|
|
425
|
+
return issues;
|
|
426
|
+
const required = Array.isArray(capabilities.required)
|
|
427
|
+
? capabilities.required.map(String)
|
|
428
|
+
: [];
|
|
429
|
+
const optional = Array.isArray(capabilities.optional)
|
|
430
|
+
? capabilities.optional.map(String)
|
|
431
|
+
: [];
|
|
432
|
+
const overlap = optional.find((capability) => required.includes(capability));
|
|
433
|
+
if (overlap) {
|
|
434
|
+
issues.push(issue("/requirements/capabilities/optional", "disjoint", `must not repeat required capability ${overlap}`));
|
|
435
|
+
}
|
|
436
|
+
const declared = new Set([...required, ...optional]);
|
|
437
|
+
const provisions = asObject(manifest.provisions);
|
|
438
|
+
if (declared.has("definitions.type-pack.apply")) {
|
|
439
|
+
if (requirements.access !== "full_collection") {
|
|
440
|
+
issues.push(issue("/requirements/access", "typePackAccess", "must be full_collection for definitions.type-pack.apply"));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (Array.isArray(provisions.type_packs)
|
|
444
|
+
&& provisions.type_packs.length > 0
|
|
445
|
+
&& !required.includes("collection.setup.apply")) {
|
|
446
|
+
issues.push(issue("/requirements/capabilities/required", "collectionSetupCapability", "must require collection.setup.apply when bundled type packs are declared"));
|
|
447
|
+
}
|
|
448
|
+
const hasConfigurationProvisions = Array.isArray(provisions.configuration)
|
|
449
|
+
&& provisions.configuration.length > 0;
|
|
450
|
+
const hasSetupProvisions = hasConfigurationProvisions
|
|
451
|
+
|| (Array.isArray(provisions.type_packs) && provisions.type_packs.length > 0);
|
|
452
|
+
if (hasConfigurationProvisions && !required.includes("collection.setup.apply")) {
|
|
453
|
+
issues.push(issue("/requirements/capabilities/required", "collectionSetupCapability", "must require collection.setup.apply when configuration provisions are declared"));
|
|
454
|
+
}
|
|
455
|
+
if (declared.has("collection.setup.apply")) {
|
|
456
|
+
if (requirements.access !== "full_collection") {
|
|
457
|
+
issues.push(issue("/requirements/access", "collectionSetupAccess", "must be full_collection for collection.setup.apply"));
|
|
458
|
+
}
|
|
459
|
+
if (!hasSetupProvisions) {
|
|
460
|
+
issues.push(issue("/provisions", "collectionSetupProvision", "must declare a configuration provision or type pack for collection.setup.apply"));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (declared.has("notifications.background-delivery")
|
|
464
|
+
&& !Array.isArray(asObject(manifest.notifications).criteria)) {
|
|
465
|
+
issues.push(issue("/notifications/criteria", "notificationCapability", "must be declared for notifications.background-delivery"));
|
|
466
|
+
}
|
|
467
|
+
const fileRequirement = asObject(requirements.files);
|
|
468
|
+
const fileActions = Array.isArray(fileRequirement.actions)
|
|
469
|
+
? new Set(fileRequirement.actions.map(String))
|
|
470
|
+
: new Set();
|
|
471
|
+
for (const action of ["list", "read", "add", "replace", "move", "delete"]) {
|
|
472
|
+
const capability = `files.${action}`;
|
|
473
|
+
if (declared.has(capability) !== fileActions.has(action)) {
|
|
474
|
+
issues.push(issue("/requirements/capabilities", "fileCapability", `${capability} and requirements.files.actions.${action} must be declared together`));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return issues;
|
|
478
|
+
}
|
|
479
|
+
function localManifestSchemaCandidate(value) {
|
|
480
|
+
const source = asObject(value);
|
|
481
|
+
if (source.distribution === "portable")
|
|
482
|
+
return value;
|
|
483
|
+
// Only URL fields need a schema-safe local substitute. A shallow copy keeps
|
|
484
|
+
// non-JSON values visible to the schema instead of silently dropping them.
|
|
485
|
+
const object = { ...source };
|
|
486
|
+
for (const field of ["homepage", "icon"]) {
|
|
487
|
+
if (typeof object[field] === "string") {
|
|
488
|
+
object[field] = schemaSafeLocalUrl(object[field]);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (Array.isArray(object.redirect_uris)) {
|
|
492
|
+
object.redirect_uris = object.redirect_uris.map((url) => typeof url === "string" ? schemaSafeLocalUrl(url) : url);
|
|
493
|
+
}
|
|
494
|
+
return object;
|
|
495
|
+
}
|
|
496
|
+
function schemaSafeLocalUrl(value) {
|
|
497
|
+
try {
|
|
498
|
+
const url = new URL(value);
|
|
499
|
+
if (url.protocol === "http:" && isLoopback(url.hostname))
|
|
500
|
+
url.protocol = "https:";
|
|
501
|
+
return url.href;
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
return value;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function secureOrAllowedLocal(url, allowLocal) {
|
|
508
|
+
return url.protocol === "https:"
|
|
509
|
+
|| (allowLocal && url.protocol === "http:" && isLoopback(url.hostname));
|
|
510
|
+
}
|
|
511
|
+
function isLoopback(hostname) {
|
|
512
|
+
return hostname === "localhost"
|
|
513
|
+
|| hostname === "127.0.0.1"
|
|
514
|
+
|| hostname === "[::1]"
|
|
515
|
+
|| hostname === "::1";
|
|
516
|
+
}
|
|
517
|
+
function invalid(issues) {
|
|
518
|
+
return { valid: false, issues };
|
|
519
|
+
}
|
|
520
|
+
function issue(path, keyword, message, params = {}) {
|
|
521
|
+
return { path, keyword, message, params };
|
|
522
|
+
}
|
|
523
|
+
function asObject(value) {
|
|
524
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
525
|
+
? value
|
|
526
|
+
: {};
|
|
527
|
+
}
|