@mdbase-dev/connect-dev 0.1.0-beta.28 → 0.1.0-beta.30
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 +11 -0
- package/dist/index.d.ts +6 -19
- package/dist/index.js +3 -196
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -30,6 +30,17 @@ mdbase-connect-dev validate-manifest public/.well-known/mdbase-app.json --allow-
|
|
|
30
30
|
mdbase-connect-dev validate-contract worklog-contract.json
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
Manifest validation uses the same canonical JSON Schema, semantic checks,
|
|
34
|
+
embedded-resource digest verification, and declaration-size bound as the
|
|
35
|
+
matching Connect server. Run it against the generated artifact in application
|
|
36
|
+
CI—not only against a handwritten source object. Failures include the exact
|
|
37
|
+
JSON-pointer path and rule.
|
|
38
|
+
|
|
39
|
+
Deployed applications can also submit `{ "manifest": ... }` to
|
|
40
|
+
`POST /v1/apps/validate`. That endpoint performs the same validation without
|
|
41
|
+
creating or updating an application registration, making it suitable for
|
|
42
|
+
release smoke tests.
|
|
43
|
+
|
|
33
44
|
Bundled application declarations support connector-controlled type-pack
|
|
34
45
|
provisioning. Put the contract, its implementing types, and any referenced
|
|
35
46
|
schemas in one `provisions.type_packs` transaction. Each manifest entry pins
|
package/dist/index.d.ts
CHANGED
|
@@ -1,23 +1,11 @@
|
|
|
1
1
|
import { MdbaseCollectionClient, type MdbaseCollectionTransport } from "@mdbase-dev/connect";
|
|
2
2
|
import type { CollectionDescription, CollectionOperation, JsonObject, TypePackProvision } from "@mdbase-dev/connect-protocol";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export type ValidationResult = {
|
|
10
|
-
valid: true;
|
|
11
|
-
issues: [];
|
|
12
|
-
} | {
|
|
13
|
-
valid: false;
|
|
14
|
-
issues: ValidationIssue[];
|
|
15
|
-
};
|
|
16
|
-
export interface ManifestValidationOptions {
|
|
17
|
-
/** Allow HTTP only for localhost/loopback developer manifests. */
|
|
18
|
-
allowLocal?: boolean;
|
|
19
|
-
}
|
|
20
|
-
export declare function validateAppManifest(value: unknown, options?: ManifestValidationOptions): ValidationResult;
|
|
3
|
+
import { formatManifestValidationIssues, validateAppManifest, type ManifestValidationIssue, type ManifestValidationOptions, type ManifestValidationResult } from "@mdbase-dev/connect-protocol/manifest";
|
|
4
|
+
export type ValidationIssue = ManifestValidationIssue;
|
|
5
|
+
export type ValidationResult = ManifestValidationResult;
|
|
6
|
+
export type { ManifestValidationOptions };
|
|
7
|
+
export { validateAppManifest };
|
|
8
|
+
export declare const formatValidationIssues: typeof formatManifestValidationIssues;
|
|
21
9
|
export declare function validateDataContract(value: unknown): ValidationResult;
|
|
22
10
|
export declare function validateProtocolValue(value: unknown, definition?: string): ValidationResult;
|
|
23
11
|
export interface DataContractDocument extends JsonObject {
|
|
@@ -117,4 +105,3 @@ export declare class SandboxTransport<Frontmatter extends JsonObject = JsonObjec
|
|
|
117
105
|
private recordResult;
|
|
118
106
|
private append;
|
|
119
107
|
}
|
|
120
|
-
export declare function formatValidationIssues(issues: ValidationIssue[]): string;
|
package/dist/index.js
CHANGED
|
@@ -2,12 +2,13 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
3
3
|
import { parseDocument } from "yaml";
|
|
4
4
|
import { MdbaseCollectionClient, connectError } from "@mdbase-dev/connect";
|
|
5
|
-
import {
|
|
6
|
-
import appManifestSchema from "@mdbase-dev/connect-protocol/schemas/mdbase-app.schema.json" with { type: "json" };
|
|
5
|
+
import { formatManifestValidationIssues, validateAppManifest } from "@mdbase-dev/connect-protocol/manifest";
|
|
7
6
|
import connectProblemSchema from "@mdbase-dev/connect-protocol/schemas/connect-problem.v1.schema.json" with { type: "json" };
|
|
8
7
|
import dataContractSchema from "@mdbase-dev/connect-protocol/schemas/data-contract.schema.json" with { type: "json" };
|
|
9
8
|
import connectProtocolSchema from "@mdbase-dev/connect-protocol/schemas/connect-protocol.v1.schema.json" with { type: "json" };
|
|
10
9
|
import filesSchema from "@mdbase-dev/connect-protocol/schemas/files.v1.schema.json" with { type: "json" };
|
|
10
|
+
export { validateAppManifest };
|
|
11
|
+
export const formatValidationIssues = formatManifestValidationIssues;
|
|
11
12
|
const ajv = new Ajv2020({
|
|
12
13
|
allErrors: true,
|
|
13
14
|
strict: true,
|
|
@@ -15,26 +16,11 @@ const ajv = new Ajv2020({
|
|
|
15
16
|
strictRequired: false,
|
|
16
17
|
formats: { "date-time": true, uri: true, uuid: true }
|
|
17
18
|
});
|
|
18
|
-
ajv.addSchema(appManifestSchema);
|
|
19
19
|
ajv.addSchema(connectProblemSchema);
|
|
20
20
|
ajv.addSchema(dataContractSchema);
|
|
21
21
|
ajv.addSchema(filesSchema);
|
|
22
22
|
ajv.addSchema(connectProtocolSchema);
|
|
23
|
-
const appManifestValidator = requiredValidator(String(appManifestSchema.$id));
|
|
24
23
|
const contractValidator = requiredValidator(String(dataContractSchema.$id));
|
|
25
|
-
export function validateAppManifest(value, options = {}) {
|
|
26
|
-
const candidate = options.allowLocal ? localManifestSchemaCandidate(value) : value;
|
|
27
|
-
const schemaResult = validationResult(appManifestValidator, candidate);
|
|
28
|
-
if (!schemaResult.valid)
|
|
29
|
-
return schemaResult;
|
|
30
|
-
const originResult = validateManifestOrigins(value, options.allowLocal === true);
|
|
31
|
-
if (!originResult.valid)
|
|
32
|
-
return originResult;
|
|
33
|
-
const capabilityResult = validateCapabilityRequirements(value);
|
|
34
|
-
if (!capabilityResult.valid)
|
|
35
|
-
return capabilityResult;
|
|
36
|
-
return validateProvisionRequirements(value);
|
|
37
|
-
}
|
|
38
24
|
export function validateDataContract(value) {
|
|
39
25
|
return validationResult(contractValidator, value);
|
|
40
26
|
}
|
|
@@ -437,9 +423,6 @@ function sandboxFileMetadata(record) {
|
|
|
437
423
|
mtime: new Date(revision).toISOString()
|
|
438
424
|
};
|
|
439
425
|
}
|
|
440
|
-
export function formatValidationIssues(issues) {
|
|
441
|
-
return issues.map((issue) => `${issue.path || "/"} ${issue.message}`).join("; ");
|
|
442
|
-
}
|
|
443
426
|
function requiredValidator(reference) {
|
|
444
427
|
const validate = ajv.getSchema(reference);
|
|
445
428
|
if (!validate)
|
|
@@ -462,182 +445,6 @@ function validationIssue(error) {
|
|
|
462
445
|
params: error.params
|
|
463
446
|
};
|
|
464
447
|
}
|
|
465
|
-
function validateManifestOrigins(value, allowLocal) {
|
|
466
|
-
const manifest = asObject(value);
|
|
467
|
-
try {
|
|
468
|
-
const homepage = new URL(String(manifest.homepage));
|
|
469
|
-
if (!secureOrAllowedLocal(homepage, allowLocal)) {
|
|
470
|
-
return semanticIssue("/homepage", "must use HTTPS (or loopback HTTP in local mode)");
|
|
471
|
-
}
|
|
472
|
-
for (const [index, redirect] of manifest.redirect_uris.entries()) {
|
|
473
|
-
const url = new URL(String(redirect));
|
|
474
|
-
if (url.origin === homepage.origin && !secureOrAllowedLocal(url, allowLocal)) {
|
|
475
|
-
return semanticIssue(`/redirect_uris/${index}`, "must use HTTPS (or loopback HTTP in local mode)");
|
|
476
|
-
}
|
|
477
|
-
const nativeAllowed = nativeRedirectMatchesApplication(url, String(manifest.id));
|
|
478
|
-
if (url.origin !== homepage.origin && !nativeAllowed) {
|
|
479
|
-
return semanticIssue(`/redirect_uris/${index}`, "must use the homepage origin or a private-use scheme matching the application ID");
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
if (manifest.icon !== undefined && new URL(String(manifest.icon)).origin !== homepage.origin) {
|
|
483
|
-
return semanticIssue("/icon", "must use the homepage origin");
|
|
484
|
-
}
|
|
485
|
-
return { valid: true, issues: [] };
|
|
486
|
-
}
|
|
487
|
-
catch {
|
|
488
|
-
return semanticIssue("/", "contains an invalid URL");
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
function nativeRedirectMatchesApplication(url, applicationId) {
|
|
492
|
-
const scheme = url.protocol.slice(0, -1);
|
|
493
|
-
return isNativeRedirectUri(url)
|
|
494
|
-
&& (scheme === applicationId || scheme.startsWith(`${applicationId}.`));
|
|
495
|
-
}
|
|
496
|
-
function validateProvisionRequirements(value) {
|
|
497
|
-
const manifest = asObject(value);
|
|
498
|
-
const requirements = asObject(manifest.requirements);
|
|
499
|
-
const requiredContracts = Array.isArray(requirements.contracts) ? requirements.contracts : [];
|
|
500
|
-
const required = new Set(requiredContracts.map((contract) => {
|
|
501
|
-
const value = asObject(contract);
|
|
502
|
-
return `${value.id}@${value.version}`;
|
|
503
|
-
}));
|
|
504
|
-
const provisions = asObject(manifest.provisions);
|
|
505
|
-
const packs = Array.isArray(provisions.type_packs)
|
|
506
|
-
? provisions.type_packs
|
|
507
|
-
: [];
|
|
508
|
-
for (const [packIndex, provisionValue] of packs.entries()) {
|
|
509
|
-
const provision = asObject(provisionValue);
|
|
510
|
-
const providedContracts = Array.isArray(provision.provides)
|
|
511
|
-
? provision.provides
|
|
512
|
-
: [];
|
|
513
|
-
for (const providedValue of providedContracts) {
|
|
514
|
-
const provided = asObject(providedValue);
|
|
515
|
-
if (!required.has(`${provided.id}@${provided.version}`)) {
|
|
516
|
-
return semanticIssue(`/provisions/type_packs/${packIndex}/provides`, "may only contain contracts required by the application");
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
const manifest = asObject(provision.manifest);
|
|
520
|
-
const declaredResources = Array.isArray(manifest.resources)
|
|
521
|
-
? manifest.resources.map(asObject)
|
|
522
|
-
: [];
|
|
523
|
-
const embeddedResources = Array.isArray(provision.resources)
|
|
524
|
-
? provision.resources.map(asObject)
|
|
525
|
-
: [];
|
|
526
|
-
const embedded = new Map(embeddedResources.map((resource) => [
|
|
527
|
-
String(resource.source),
|
|
528
|
-
resource.document
|
|
529
|
-
]));
|
|
530
|
-
if (embedded.size !== embeddedResources.length
|
|
531
|
-
|| declaredResources.length !== embeddedResources.length) {
|
|
532
|
-
return semanticIssue(`/provisions/type_packs/${packIndex}/resources`, "must match manifest source paths exactly");
|
|
533
|
-
}
|
|
534
|
-
for (const [resourceIndex, resource] of declaredResources.entries()) {
|
|
535
|
-
const source = String(resource.source);
|
|
536
|
-
const document = embedded.get(source);
|
|
537
|
-
if (typeof document !== "string") {
|
|
538
|
-
return semanticIssue(`/provisions/type_packs/${packIndex}/resources`, `is missing manifest source ${source}`);
|
|
539
|
-
}
|
|
540
|
-
const digest = `sha256:${createHash("sha256").update(document).digest("hex")}`;
|
|
541
|
-
if (digest !== resource.digest) {
|
|
542
|
-
return semanticIssue(`/provisions/type_packs/${packIndex}/manifest/resources/${resourceIndex}/digest`, "does not match the embedded document");
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
return { valid: true, issues: [] };
|
|
547
|
-
}
|
|
548
|
-
function validateCapabilityRequirements(value) {
|
|
549
|
-
const manifest = asObject(value);
|
|
550
|
-
const requirements = asObject(manifest.requirements);
|
|
551
|
-
const capabilities = asObject(requirements.capabilities);
|
|
552
|
-
if (Object.keys(capabilities).length === 0)
|
|
553
|
-
return { valid: true, issues: [] };
|
|
554
|
-
const required = Array.isArray(capabilities.required)
|
|
555
|
-
? capabilities.required.map(String)
|
|
556
|
-
: [];
|
|
557
|
-
const optional = Array.isArray(capabilities.optional)
|
|
558
|
-
? capabilities.optional.map(String)
|
|
559
|
-
: [];
|
|
560
|
-
if (new Set(required).size !== required.length) {
|
|
561
|
-
return semanticIssue("/requirements/capabilities/required", "must not contain duplicates");
|
|
562
|
-
}
|
|
563
|
-
if (new Set(optional).size !== optional.length) {
|
|
564
|
-
return semanticIssue("/requirements/capabilities/optional", "must not contain duplicates");
|
|
565
|
-
}
|
|
566
|
-
const overlap = optional.find((capability) => required.includes(capability));
|
|
567
|
-
if (overlap) {
|
|
568
|
-
return semanticIssue("/requirements/capabilities/optional", `must not repeat required capability ${overlap}`);
|
|
569
|
-
}
|
|
570
|
-
const declared = new Set([...required, ...optional]);
|
|
571
|
-
const provisions = asObject(manifest.provisions);
|
|
572
|
-
if (Array.isArray(requirements.contracts)
|
|
573
|
-
&& requirements.contracts.length > 0
|
|
574
|
-
&& requirements.access !== "full_collection"
|
|
575
|
-
&& !required.includes("definitions.contracts.current")) {
|
|
576
|
-
return semanticIssue("/requirements/capabilities/required", "must require definitions.contracts.current for contract-scoped requirements");
|
|
577
|
-
}
|
|
578
|
-
if (declared.has("definitions.type-pack.apply")) {
|
|
579
|
-
if (requirements.access !== "full_collection") {
|
|
580
|
-
return semanticIssue("/requirements/access", "must be full_collection for definitions.type-pack.apply");
|
|
581
|
-
}
|
|
582
|
-
if (!required.includes("definitions.type-pack.apply")) {
|
|
583
|
-
return semanticIssue("/requirements/capabilities/required", "must require definitions.type-pack.apply when it is declared");
|
|
584
|
-
}
|
|
585
|
-
if (!Array.isArray(provisions.type_packs) || provisions.type_packs.length === 0) {
|
|
586
|
-
return semanticIssue("/provisions/type_packs", "must contain a pack for definitions.type-pack.apply");
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
if (declared.has("notifications.background-delivery")
|
|
590
|
-
&& !Array.isArray(asObject(manifest.notifications).criteria)) {
|
|
591
|
-
return semanticIssue("/notifications/criteria", "must be declared for notifications.background-delivery");
|
|
592
|
-
}
|
|
593
|
-
const fileRequirement = asObject(requirements.files);
|
|
594
|
-
const fileActions = Array.isArray(fileRequirement.actions)
|
|
595
|
-
? new Set(fileRequirement.actions.map(String))
|
|
596
|
-
: new Set();
|
|
597
|
-
for (const action of ["list", "read", "add", "replace", "move", "delete"]) {
|
|
598
|
-
const capability = `files.${action}`;
|
|
599
|
-
if (declared.has(capability) !== fileActions.has(action)) {
|
|
600
|
-
return semanticIssue("/requirements/capabilities", `${capability} and requirements.files.actions.${action} must be declared together`);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
return { valid: true, issues: [] };
|
|
604
|
-
}
|
|
605
|
-
function localManifestSchemaCandidate(value) {
|
|
606
|
-
const candidate = clone(value);
|
|
607
|
-
const object = asObject(candidate);
|
|
608
|
-
for (const field of ["homepage", "icon"]) {
|
|
609
|
-
if (typeof object[field] === "string")
|
|
610
|
-
object[field] = schemaSafeLocalUrl(object[field]);
|
|
611
|
-
}
|
|
612
|
-
if (Array.isArray(object.redirect_uris)) {
|
|
613
|
-
object.redirect_uris = object.redirect_uris.map((url) => typeof url === "string" ? schemaSafeLocalUrl(url) : url);
|
|
614
|
-
}
|
|
615
|
-
return candidate;
|
|
616
|
-
}
|
|
617
|
-
function schemaSafeLocalUrl(value) {
|
|
618
|
-
try {
|
|
619
|
-
const url = new URL(value);
|
|
620
|
-
if (url.protocol === "http:" && isLoopback(url.hostname))
|
|
621
|
-
url.protocol = "https:";
|
|
622
|
-
return url.href;
|
|
623
|
-
}
|
|
624
|
-
catch {
|
|
625
|
-
return value;
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
function secureOrAllowedLocal(url, allowLocal) {
|
|
629
|
-
return url.protocol === "https:"
|
|
630
|
-
|| (allowLocal && url.protocol === "http:" && isLoopback(url.hostname));
|
|
631
|
-
}
|
|
632
|
-
function isLoopback(hostname) {
|
|
633
|
-
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
634
|
-
}
|
|
635
|
-
function semanticIssue(path, message) {
|
|
636
|
-
return {
|
|
637
|
-
valid: false,
|
|
638
|
-
issues: [{ path, keyword: "semantic", message, params: {} }]
|
|
639
|
-
};
|
|
640
|
-
}
|
|
641
448
|
function definitionIssue(path, keyword, message) {
|
|
642
449
|
return { path, keyword, message, params: {} };
|
|
643
450
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mdbase-dev/connect-dev",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.30",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"ajv": "^8.20.0",
|
|
28
28
|
"yaml": "^2.9.0",
|
|
29
|
-
"@mdbase-dev/connect": "0.1.0-beta.
|
|
30
|
-
"@mdbase-dev/connect-protocol": "0.1.0-beta.
|
|
29
|
+
"@mdbase-dev/connect": "0.1.0-beta.30",
|
|
30
|
+
"@mdbase-dev/connect-protocol": "0.1.0-beta.30"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "^24.0.0",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"vitest": "^4.1.10"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
|
-
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
38
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node -e \"require('node:fs').chmodSync('dist/cli.js',0o755)\"",
|
|
39
39
|
"test": "vitest run",
|
|
40
40
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
41
41
|
}
|