@mdbase-dev/connect-dev 0.1.0-beta.21
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/README.md +62 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +30 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +566 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mdbase connect contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @mdbase-dev/connect-dev
|
|
2
|
+
|
|
3
|
+
Developer tools for applications built on mdbase connect.
|
|
4
|
+
|
|
5
|
+
The package provides canonical manifest and data-contract validation plus an
|
|
6
|
+
in-memory transport for frontend tests. The sandbox supports typed CRUD,
|
|
7
|
+
revision preconditions, read defaults, type-filtered pagination, and change
|
|
8
|
+
cursors. It rejects CEL filters and ordering so semantic tests cannot silently
|
|
9
|
+
depend on an approximation; run those against a real connector.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createSandbox } from "@mdbase-dev/connect-dev";
|
|
13
|
+
|
|
14
|
+
const { client } = createSandbox({
|
|
15
|
+
records: [{
|
|
16
|
+
path: "tasks/first.md",
|
|
17
|
+
types: ["task"],
|
|
18
|
+
frontmatter: { type: "task", title: "First" }
|
|
19
|
+
}]
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const tasks = await client.query({ types: ["task"] });
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Validate artifacts from a project script:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
mdbase-connect-dev validate-manifest public/.well-known/mdbase-app.json
|
|
29
|
+
mdbase-connect-dev validate-manifest public/.well-known/mdbase-app.json --allow-local
|
|
30
|
+
mdbase-connect-dev validate-contract worklog-contract.json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Bundled application declarations support connector-controlled type-pack
|
|
34
|
+
provisioning. Put the contract, its implementing types, and any referenced
|
|
35
|
+
schemas in one `provisions.type_packs` transaction. Each manifest entry pins
|
|
36
|
+
the exact destination and SHA-256 digest. `provides` declares which exact
|
|
37
|
+
contracts the pack satisfies; auxiliary types belong in the same pack. The
|
|
38
|
+
validator rejects missing sources, digest mismatches, and claims for contracts
|
|
39
|
+
the application does not require.
|
|
40
|
+
|
|
41
|
+
Use `defineTypePack` to generate the manifest entries and digests from readable
|
|
42
|
+
documents:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { defineTypePack } from "@mdbase-dev/connect-dev";
|
|
46
|
+
|
|
47
|
+
const provision = defineTypePack({
|
|
48
|
+
id: "example.tasks",
|
|
49
|
+
version: "1.0.0",
|
|
50
|
+
provides: [{ id: "example.task", version: "1.0.0" }],
|
|
51
|
+
resources: [
|
|
52
|
+
{ kind: "contract", source: "_contracts/example.task.md", document: contractDocument },
|
|
53
|
+
{ kind: "type", source: "_types/task.md", document: typeDocument }
|
|
54
|
+
]
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Native applications may add a reverse-domain private-use callback scheme that
|
|
59
|
+
matches the v1 manifest's application ID, such as
|
|
60
|
+
`example.tasks.desktop://auth/mdbase/callback` for
|
|
61
|
+
`id: "example.tasks.desktop"`. Native authorization still uses PKCE and should
|
|
62
|
+
open the authorization URL in the system browser.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { formatValidationIssues, validateAppManifest, validateDataContract } from "./index.js";
|
|
5
|
+
const arguments_ = process.argv.slice(2);
|
|
6
|
+
const allowLocal = arguments_.includes("--allow-local");
|
|
7
|
+
const [command, source] = arguments_.filter((argument) => argument !== "--allow-local");
|
|
8
|
+
if (!source || !["validate-manifest", "validate-contract"].includes(command ?? "")) {
|
|
9
|
+
console.error("Usage: mdbase-connect-dev <validate-manifest|validate-contract> <file.json> [--allow-local]");
|
|
10
|
+
process.exitCode = 2;
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
try {
|
|
14
|
+
const value = JSON.parse(await readFile(resolve(source), "utf8"));
|
|
15
|
+
const result = command === "validate-manifest"
|
|
16
|
+
? validateAppManifest(value, { allowLocal })
|
|
17
|
+
: validateDataContract(value);
|
|
18
|
+
if (result.valid) {
|
|
19
|
+
console.log(`${source} is valid`);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
console.error(formatValidationIssues(result.issues));
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { MdbaseCollectionClient, type MdbaseCollectionTransport } from "@mdbase-dev/connect";
|
|
2
|
+
import type { CollectionDescription, CollectionOperation, JsonObject, TypePackProvision } from "@mdbase-dev/connect-protocol";
|
|
3
|
+
export interface ValidationIssue {
|
|
4
|
+
path: string;
|
|
5
|
+
keyword: string;
|
|
6
|
+
message: string;
|
|
7
|
+
params: Record<string, unknown>;
|
|
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;
|
|
21
|
+
export declare function validateDataContract(value: unknown): ValidationResult;
|
|
22
|
+
export declare function validateProtocolValue(value: unknown, definition?: string): ValidationResult;
|
|
23
|
+
export interface DataContractDocument extends JsonObject {
|
|
24
|
+
kind: "mdbase.contract";
|
|
25
|
+
contract_type: "record" | "event" | "action";
|
|
26
|
+
id: string;
|
|
27
|
+
version: string;
|
|
28
|
+
}
|
|
29
|
+
export interface RecordDataContractDocument extends DataContractDocument {
|
|
30
|
+
contract_type: "record";
|
|
31
|
+
record_schema: JsonObject;
|
|
32
|
+
}
|
|
33
|
+
export interface EventDataContractDocument extends DataContractDocument {
|
|
34
|
+
contract_type: "event";
|
|
35
|
+
data_schema: JsonObject;
|
|
36
|
+
}
|
|
37
|
+
export interface ActionDataContractDocument extends DataContractDocument {
|
|
38
|
+
contract_type: "action";
|
|
39
|
+
input_schema: JsonObject;
|
|
40
|
+
}
|
|
41
|
+
export declare function defineDataContract<const Contract extends RecordDataContractDocument | EventDataContractDocument | ActionDataContractDocument>(contract: Contract): Contract;
|
|
42
|
+
export declare class DataContractDefinitionError extends Error {
|
|
43
|
+
readonly issues: ValidationIssue[];
|
|
44
|
+
constructor(issues: ValidationIssue[]);
|
|
45
|
+
}
|
|
46
|
+
export interface TypePackResourceInput {
|
|
47
|
+
kind: "contract" | "type" | "schema";
|
|
48
|
+
source: string;
|
|
49
|
+
/** Collection-relative install location. Defaults to `source`. */
|
|
50
|
+
target?: string;
|
|
51
|
+
/** Exact UTF-8 resource document included in the provision. */
|
|
52
|
+
document: string;
|
|
53
|
+
}
|
|
54
|
+
export interface TypePackDefinition {
|
|
55
|
+
id: string;
|
|
56
|
+
version: string;
|
|
57
|
+
name?: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
resources: TypePackResourceInput[];
|
|
60
|
+
provides: Array<{
|
|
61
|
+
id: string;
|
|
62
|
+
version: string;
|
|
63
|
+
}>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build a complete, digest-pinned type-pack provision from readable source
|
|
67
|
+
* documents. This keeps application manifests reviewable without asking
|
|
68
|
+
* developers to calculate SHA-256 values by hand.
|
|
69
|
+
*/
|
|
70
|
+
export declare function defineTypePack(definition: TypePackDefinition): TypePackProvision;
|
|
71
|
+
export declare class TypePackDefinitionError extends Error {
|
|
72
|
+
readonly issues: ValidationIssue[];
|
|
73
|
+
constructor(issues: ValidationIssue[]);
|
|
74
|
+
}
|
|
75
|
+
export interface SandboxRecord<Frontmatter extends JsonObject = JsonObject> {
|
|
76
|
+
path: string;
|
|
77
|
+
frontmatter?: Frontmatter;
|
|
78
|
+
body?: string;
|
|
79
|
+
types?: string[];
|
|
80
|
+
}
|
|
81
|
+
export interface SandboxOptions<Frontmatter extends JsonObject = JsonObject> {
|
|
82
|
+
description?: Partial<CollectionDescription>;
|
|
83
|
+
records?: SandboxRecord<Frontmatter>[];
|
|
84
|
+
}
|
|
85
|
+
export interface DeveloperSandbox<Frontmatter extends JsonObject = JsonObject> {
|
|
86
|
+
client: MdbaseCollectionClient<Frontmatter>;
|
|
87
|
+
transport: SandboxTransport<Frontmatter>;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Build a deterministic, in-memory provider for frontend development.
|
|
91
|
+
*
|
|
92
|
+
* It intentionally implements the transport contract, CRUD preconditions,
|
|
93
|
+
* defaults, type filtering, pagination, and change cursors. It rejects query
|
|
94
|
+
* expression features so tests cannot silently rely on a non-conformant CEL
|
|
95
|
+
* approximation; use a real connector for semantic integration tests.
|
|
96
|
+
*/
|
|
97
|
+
export declare function createSandbox<Frontmatter extends JsonObject = JsonObject>(options?: SandboxOptions<Frontmatter>): DeveloperSandbox<Frontmatter>;
|
|
98
|
+
export declare class SandboxTransport<Frontmatter extends JsonObject = JsonObject> implements MdbaseCollectionTransport {
|
|
99
|
+
private readonly records;
|
|
100
|
+
private readonly events;
|
|
101
|
+
private revisionSequence;
|
|
102
|
+
readonly description: CollectionDescription;
|
|
103
|
+
constructor(options?: SandboxOptions<Frontmatter>);
|
|
104
|
+
seed(record: SandboxRecord<Frontmatter>): void;
|
|
105
|
+
snapshot(): SandboxRecord<Frontmatter>[];
|
|
106
|
+
operation<Result>(operation: CollectionOperation, input: unknown): Promise<Result>;
|
|
107
|
+
private changes;
|
|
108
|
+
private read;
|
|
109
|
+
private query;
|
|
110
|
+
private create;
|
|
111
|
+
private update;
|
|
112
|
+
private delete;
|
|
113
|
+
private rename;
|
|
114
|
+
private storedRecord;
|
|
115
|
+
private recordResult;
|
|
116
|
+
private append;
|
|
117
|
+
}
|
|
118
|
+
export declare function formatValidationIssues(issues: ValidationIssue[]): string;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
3
|
+
import { MdbaseCollectionClient, MdbaseConnectError } from "@mdbase-dev/connect";
|
|
4
|
+
import { isNativeRedirectUri } from "@mdbase-dev/connect-protocol";
|
|
5
|
+
import appManifestSchema from "@mdbase-dev/connect-protocol/schemas/mdbase-app.schema.json" with { type: "json" };
|
|
6
|
+
import dataContractSchema from "@mdbase-dev/connect-protocol/schemas/data-contract.schema.json" with { type: "json" };
|
|
7
|
+
import connectProtocolSchema from "@mdbase-dev/connect-protocol/schemas/connect-protocol.v1.schema.json" with { type: "json" };
|
|
8
|
+
const ajv = new Ajv2020({
|
|
9
|
+
allErrors: true,
|
|
10
|
+
strict: true,
|
|
11
|
+
formats: { "date-time": true, uri: true }
|
|
12
|
+
});
|
|
13
|
+
ajv.addSchema(appManifestSchema);
|
|
14
|
+
ajv.addSchema(dataContractSchema);
|
|
15
|
+
ajv.addSchema(connectProtocolSchema);
|
|
16
|
+
const appManifestValidator = requiredValidator(String(appManifestSchema.$id));
|
|
17
|
+
const contractValidator = requiredValidator(String(dataContractSchema.$id));
|
|
18
|
+
export function validateAppManifest(value, options = {}) {
|
|
19
|
+
const candidate = options.allowLocal ? localManifestSchemaCandidate(value) : value;
|
|
20
|
+
const schemaResult = validationResult(appManifestValidator, candidate);
|
|
21
|
+
if (!schemaResult.valid)
|
|
22
|
+
return schemaResult;
|
|
23
|
+
const originResult = validateManifestOrigins(value, options.allowLocal === true);
|
|
24
|
+
if (!originResult.valid)
|
|
25
|
+
return originResult;
|
|
26
|
+
return validateProvisionRequirements(value);
|
|
27
|
+
}
|
|
28
|
+
export function validateDataContract(value) {
|
|
29
|
+
return validationResult(contractValidator, value);
|
|
30
|
+
}
|
|
31
|
+
export function validateProtocolValue(value, definition) {
|
|
32
|
+
const reference = definition
|
|
33
|
+
? `${String(connectProtocolSchema.$id)}#/$defs/${definition}`
|
|
34
|
+
: String(connectProtocolSchema.$id);
|
|
35
|
+
return validationResult(requiredValidator(reference), value);
|
|
36
|
+
}
|
|
37
|
+
export function defineDataContract(contract) {
|
|
38
|
+
const result = validateDataContract(contract);
|
|
39
|
+
if (!result.valid) {
|
|
40
|
+
throw new DataContractDefinitionError(result.issues);
|
|
41
|
+
}
|
|
42
|
+
return contract;
|
|
43
|
+
}
|
|
44
|
+
export class DataContractDefinitionError extends Error {
|
|
45
|
+
issues;
|
|
46
|
+
constructor(issues) {
|
|
47
|
+
super(`Invalid data contract: ${formatValidationIssues(issues)}`);
|
|
48
|
+
this.issues = issues;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build a complete, digest-pinned type-pack provision from readable source
|
|
53
|
+
* documents. This keeps application manifests reviewable without asking
|
|
54
|
+
* developers to calculate SHA-256 values by hand.
|
|
55
|
+
*/
|
|
56
|
+
export function defineTypePack(definition) {
|
|
57
|
+
const provision = {
|
|
58
|
+
manifest: {
|
|
59
|
+
kind: "mdbase.type-pack",
|
|
60
|
+
id: definition.id,
|
|
61
|
+
version: definition.version,
|
|
62
|
+
...(definition.name ? { name: definition.name } : {}),
|
|
63
|
+
...(definition.description ? { description: definition.description } : {}),
|
|
64
|
+
resources: definition.resources.map(({ kind, source, target = source, document }) => ({
|
|
65
|
+
kind,
|
|
66
|
+
source,
|
|
67
|
+
target,
|
|
68
|
+
digest: `sha256:${createHash("sha256").update(document).digest("hex")}`
|
|
69
|
+
}))
|
|
70
|
+
},
|
|
71
|
+
resources: definition.resources.map(({ source, document }) => ({ source, document })),
|
|
72
|
+
provides: definition.provides.map((contract) => ({ ...contract }))
|
|
73
|
+
};
|
|
74
|
+
const result = validateProtocolValue(provision, "typePackProvision");
|
|
75
|
+
if (!result.valid)
|
|
76
|
+
throw new TypePackDefinitionError(result.issues);
|
|
77
|
+
return provision;
|
|
78
|
+
}
|
|
79
|
+
export class TypePackDefinitionError extends Error {
|
|
80
|
+
issues;
|
|
81
|
+
constructor(issues) {
|
|
82
|
+
super(`Invalid type pack: ${formatValidationIssues(issues)}`);
|
|
83
|
+
this.issues = issues;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Build a deterministic, in-memory provider for frontend development.
|
|
88
|
+
*
|
|
89
|
+
* It intentionally implements the transport contract, CRUD preconditions,
|
|
90
|
+
* defaults, type filtering, pagination, and change cursors. It rejects query
|
|
91
|
+
* expression features so tests cannot silently rely on a non-conformant CEL
|
|
92
|
+
* approximation; use a real connector for semantic integration tests.
|
|
93
|
+
*/
|
|
94
|
+
export function createSandbox(options = {}) {
|
|
95
|
+
const transport = new SandboxTransport(options);
|
|
96
|
+
return { transport, client: new MdbaseCollectionClient(transport) };
|
|
97
|
+
}
|
|
98
|
+
export class SandboxTransport {
|
|
99
|
+
records = new Map();
|
|
100
|
+
events = [];
|
|
101
|
+
revisionSequence = 0;
|
|
102
|
+
description;
|
|
103
|
+
constructor(options = {}) {
|
|
104
|
+
this.description = sandboxDescription(options.description);
|
|
105
|
+
for (const record of options.records ?? [])
|
|
106
|
+
this.seed(record);
|
|
107
|
+
this.events.length = 0;
|
|
108
|
+
}
|
|
109
|
+
seed(record) {
|
|
110
|
+
assertSafePath(record.path);
|
|
111
|
+
this.records.set(record.path, this.storedRecord({
|
|
112
|
+
...record,
|
|
113
|
+
frontmatter: asObject(record.frontmatter)
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
snapshot() {
|
|
117
|
+
return [...this.records.values()]
|
|
118
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
119
|
+
.map((record) => ({
|
|
120
|
+
path: record.path,
|
|
121
|
+
frontmatter: clone(record.frontmatter),
|
|
122
|
+
body: record.body,
|
|
123
|
+
types: [...record.types]
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
async operation(operation, input) {
|
|
127
|
+
const object = asObject(input);
|
|
128
|
+
let result;
|
|
129
|
+
switch (operation) {
|
|
130
|
+
case "describe":
|
|
131
|
+
result = clone(this.description);
|
|
132
|
+
break;
|
|
133
|
+
case "changes":
|
|
134
|
+
result = this.changes(object);
|
|
135
|
+
break;
|
|
136
|
+
case "read":
|
|
137
|
+
result = this.read(object);
|
|
138
|
+
break;
|
|
139
|
+
case "query":
|
|
140
|
+
result = this.query(object);
|
|
141
|
+
break;
|
|
142
|
+
case "validate":
|
|
143
|
+
result = envelope({});
|
|
144
|
+
break;
|
|
145
|
+
case "create":
|
|
146
|
+
result = this.create(object);
|
|
147
|
+
break;
|
|
148
|
+
case "update":
|
|
149
|
+
result = this.update(object);
|
|
150
|
+
break;
|
|
151
|
+
case "delete":
|
|
152
|
+
result = this.delete(object);
|
|
153
|
+
break;
|
|
154
|
+
case "rename":
|
|
155
|
+
result = this.rename(object);
|
|
156
|
+
break;
|
|
157
|
+
default: throw new MdbaseConnectError("unsupported_operation", `Unsupported sandbox operation: ${operation}`);
|
|
158
|
+
}
|
|
159
|
+
return clone(result);
|
|
160
|
+
}
|
|
161
|
+
changes(input) {
|
|
162
|
+
const current = this.events.length;
|
|
163
|
+
if (input.after === undefined) {
|
|
164
|
+
return { events: [], cursor: current, has_more: false, reset: false };
|
|
165
|
+
}
|
|
166
|
+
const after = integer(input.after, "after", 0);
|
|
167
|
+
const limit = integer(input.limit ?? 200, "limit", 1);
|
|
168
|
+
const available = this.events.filter((event) => event.cursor > after);
|
|
169
|
+
const events = available.slice(0, limit);
|
|
170
|
+
return {
|
|
171
|
+
events,
|
|
172
|
+
cursor: events.at(-1)?.cursor ?? after,
|
|
173
|
+
has_more: available.length > events.length,
|
|
174
|
+
reset: false
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
read(input) {
|
|
178
|
+
const path = string(input.path, "path");
|
|
179
|
+
const record = this.records.get(path);
|
|
180
|
+
if (!record)
|
|
181
|
+
return invalid("file_not_found", `File not found: ${path}`, path);
|
|
182
|
+
return envelope(this.recordResult(record));
|
|
183
|
+
}
|
|
184
|
+
query(input) {
|
|
185
|
+
if (input.where !== undefined || input.order_by !== undefined) {
|
|
186
|
+
throw new MdbaseConnectError("sandbox_unsupported", "The in-memory sandbox does not emulate CEL or ordering. Run this test against a real connector.");
|
|
187
|
+
}
|
|
188
|
+
const types = Array.isArray(input.types)
|
|
189
|
+
? input.types.filter((value) => typeof value === "string")
|
|
190
|
+
: [];
|
|
191
|
+
const all = [...this.records.values()]
|
|
192
|
+
.filter((record) => types.length === 0 || record.types.some((type) => types.includes(type)))
|
|
193
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
194
|
+
const offset = integer(input.offset ?? 0, "offset", 0);
|
|
195
|
+
const limit = integer(input.limit ?? all.length, "limit", 0);
|
|
196
|
+
const selected = all.slice(offset, offset + limit).map((record) => {
|
|
197
|
+
const document = this.recordResult(record);
|
|
198
|
+
const result = {
|
|
199
|
+
path: document.path,
|
|
200
|
+
types: document.types,
|
|
201
|
+
file: { ...document.file, path: document.path }
|
|
202
|
+
};
|
|
203
|
+
const mode = input.frontmatter_mode ?? "effective";
|
|
204
|
+
if (mode === "persisted" || mode === "both") {
|
|
205
|
+
result.frontmatter = document.frontmatter;
|
|
206
|
+
}
|
|
207
|
+
if (mode === "effective" || mode === "both") {
|
|
208
|
+
result.effective_frontmatter = document.effective_frontmatter;
|
|
209
|
+
}
|
|
210
|
+
if (input.include_body !== true)
|
|
211
|
+
delete result.body;
|
|
212
|
+
else
|
|
213
|
+
result.body = document.body;
|
|
214
|
+
return result;
|
|
215
|
+
});
|
|
216
|
+
return envelope({
|
|
217
|
+
results: selected,
|
|
218
|
+
meta: {
|
|
219
|
+
total_count: all.length,
|
|
220
|
+
has_more: offset + selected.length < all.length
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
create(input) {
|
|
225
|
+
const path = string(input.path, "path");
|
|
226
|
+
assertSafePath(path);
|
|
227
|
+
if (this.records.has(path))
|
|
228
|
+
return invalid("path_conflict", `File already exists: ${path}`, path);
|
|
229
|
+
if (input.if_revision !== undefined) {
|
|
230
|
+
return invalid("concurrent_modification", `File does not match the requested revision: ${path}`, path);
|
|
231
|
+
}
|
|
232
|
+
const frontmatter = asObject(input.frontmatter);
|
|
233
|
+
const type = typeof input.type === "string" ? input.type.toLowerCase() : undefined;
|
|
234
|
+
const record = this.storedRecord({
|
|
235
|
+
path,
|
|
236
|
+
frontmatter,
|
|
237
|
+
body: typeof input.body === "string" ? input.body : "",
|
|
238
|
+
types: type ? [type] : explicitTypes(frontmatter)
|
|
239
|
+
});
|
|
240
|
+
this.records.set(path, record);
|
|
241
|
+
this.append("mdbase.record.created", record, { path, types: record.types });
|
|
242
|
+
return envelope(this.recordResult(record));
|
|
243
|
+
}
|
|
244
|
+
update(input) {
|
|
245
|
+
const path = string(input.path, "path");
|
|
246
|
+
const current = this.records.get(path);
|
|
247
|
+
if (!current)
|
|
248
|
+
return invalid("file_not_found", `File not found: ${path}`, path);
|
|
249
|
+
const revisionError = checkRevision(input, current);
|
|
250
|
+
if (revisionError)
|
|
251
|
+
return revisionError;
|
|
252
|
+
const patch = asObject(input.patch ?? {});
|
|
253
|
+
const frontmatter = clone(current.frontmatter);
|
|
254
|
+
for (const [field, value] of Object.entries(patch))
|
|
255
|
+
frontmatter[field] = clone(value);
|
|
256
|
+
const record = this.storedRecord({
|
|
257
|
+
path,
|
|
258
|
+
frontmatter: frontmatter,
|
|
259
|
+
body: typeof input.body === "string" ? input.body : current.body,
|
|
260
|
+
types: explicitTypes(frontmatter).length > 0 ? explicitTypes(frontmatter) : current.types
|
|
261
|
+
});
|
|
262
|
+
this.records.set(path, record);
|
|
263
|
+
this.append("mdbase.record.modified", record, { path, types: record.types, changed_fields: Object.keys(patch) });
|
|
264
|
+
return envelope(this.recordResult(record));
|
|
265
|
+
}
|
|
266
|
+
delete(input) {
|
|
267
|
+
const path = string(input.path, "path");
|
|
268
|
+
const current = this.records.get(path);
|
|
269
|
+
if (!current)
|
|
270
|
+
return invalid("file_not_found", `File not found: ${path}`, path);
|
|
271
|
+
const revisionError = checkRevision(input, current);
|
|
272
|
+
if (revisionError)
|
|
273
|
+
return revisionError;
|
|
274
|
+
if (input.dry_run === true) {
|
|
275
|
+
return envelope({
|
|
276
|
+
path,
|
|
277
|
+
deleted: false,
|
|
278
|
+
dry_run: true,
|
|
279
|
+
would_delete: true
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
this.records.delete(path);
|
|
283
|
+
this.append("mdbase.record.deleted", current, { path, types: current.types });
|
|
284
|
+
return envelope({ path, deleted: true });
|
|
285
|
+
}
|
|
286
|
+
rename(input) {
|
|
287
|
+
const from = string(input.from, "from");
|
|
288
|
+
const to = string(input.to, "to");
|
|
289
|
+
assertSafePath(to);
|
|
290
|
+
const current = this.records.get(from);
|
|
291
|
+
if (!current)
|
|
292
|
+
return invalid("file_not_found", `File not found: ${from}`, from);
|
|
293
|
+
const revisionError = checkRevision(input, current);
|
|
294
|
+
if (revisionError)
|
|
295
|
+
return revisionError;
|
|
296
|
+
if (this.records.has(to))
|
|
297
|
+
return invalid("path_conflict", `File already exists: ${to}`, to);
|
|
298
|
+
if (input.dry_run === true) {
|
|
299
|
+
return envelope({
|
|
300
|
+
from,
|
|
301
|
+
to,
|
|
302
|
+
dry_run: true,
|
|
303
|
+
would_rename: true
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
this.records.delete(from);
|
|
307
|
+
const record = this.storedRecord({ ...current, path: to });
|
|
308
|
+
this.records.set(to, record);
|
|
309
|
+
this.append("mdbase.record.renamed", record, { from, to, types: record.types });
|
|
310
|
+
return envelope({ ...this.recordResult(record), from, to, references_updated: [] });
|
|
311
|
+
}
|
|
312
|
+
storedRecord(record) {
|
|
313
|
+
return {
|
|
314
|
+
path: record.path,
|
|
315
|
+
frontmatter: clone(record.frontmatter),
|
|
316
|
+
body: record.body ?? "",
|
|
317
|
+
types: [...(record.types ?? explicitTypes(record.frontmatter))],
|
|
318
|
+
revision: `sandbox:${++this.revisionSequence}`
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
recordResult(record) {
|
|
322
|
+
const effective = clone(record.frontmatter);
|
|
323
|
+
for (const typeName of record.types) {
|
|
324
|
+
const type = this.description.types.find((candidate) => candidate.name === typeName);
|
|
325
|
+
const defaults = asObject(type?.collection?.read_defaults);
|
|
326
|
+
for (const [field, value] of Object.entries(defaults)) {
|
|
327
|
+
if (!(field in effective))
|
|
328
|
+
effective[field] = clone(value);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
path: record.path,
|
|
333
|
+
frontmatter: clone(record.frontmatter),
|
|
334
|
+
effective_frontmatter: effective,
|
|
335
|
+
body: record.body,
|
|
336
|
+
types: [...record.types],
|
|
337
|
+
revision: record.revision,
|
|
338
|
+
file: sandboxFileMetadata(record)
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
append(type, record, payload) {
|
|
342
|
+
const cursor = this.events.length + 1;
|
|
343
|
+
this.events.push({
|
|
344
|
+
cursor,
|
|
345
|
+
type,
|
|
346
|
+
occurred_at: new Date(cursor).toISOString(),
|
|
347
|
+
payload: { ...clone(payload), revision: record.revision }
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function sandboxFileMetadata(record) {
|
|
352
|
+
const segments = record.path.split("/");
|
|
353
|
+
const name = segments.at(-1) ?? record.path;
|
|
354
|
+
const folder = segments.slice(0, -1).join("/");
|
|
355
|
+
const revision = Number(record.revision.split(":").at(-1) ?? 0);
|
|
356
|
+
const serialized = `---\n${JSON.stringify(record.frontmatter)}\n---\n${record.body}`;
|
|
357
|
+
return {
|
|
358
|
+
name,
|
|
359
|
+
folder,
|
|
360
|
+
size: new TextEncoder().encode(serialized).byteLength,
|
|
361
|
+
mtime: new Date(revision).toISOString()
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
export function formatValidationIssues(issues) {
|
|
365
|
+
return issues.map((issue) => `${issue.path || "/"} ${issue.message}`).join("; ");
|
|
366
|
+
}
|
|
367
|
+
function requiredValidator(reference) {
|
|
368
|
+
const validate = ajv.getSchema(reference);
|
|
369
|
+
if (!validate)
|
|
370
|
+
throw new Error(`Canonical schema is unavailable: ${reference}`);
|
|
371
|
+
return validate;
|
|
372
|
+
}
|
|
373
|
+
function validationResult(validate, value) {
|
|
374
|
+
if (validate(value))
|
|
375
|
+
return { valid: true, issues: [] };
|
|
376
|
+
return {
|
|
377
|
+
valid: false,
|
|
378
|
+
issues: (validate.errors ?? []).map(validationIssue)
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
function validationIssue(error) {
|
|
382
|
+
return {
|
|
383
|
+
path: error.instancePath,
|
|
384
|
+
keyword: error.keyword,
|
|
385
|
+
message: error.message ?? "is invalid",
|
|
386
|
+
params: error.params
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
function validateManifestOrigins(value, allowLocal) {
|
|
390
|
+
const manifest = asObject(value);
|
|
391
|
+
try {
|
|
392
|
+
const homepage = new URL(String(manifest.homepage));
|
|
393
|
+
if (!secureOrAllowedLocal(homepage, allowLocal)) {
|
|
394
|
+
return semanticIssue("/homepage", "must use HTTPS (or loopback HTTP in local mode)");
|
|
395
|
+
}
|
|
396
|
+
for (const [index, redirect] of manifest.redirect_uris.entries()) {
|
|
397
|
+
const url = new URL(String(redirect));
|
|
398
|
+
if (url.origin === homepage.origin && !secureOrAllowedLocal(url, allowLocal)) {
|
|
399
|
+
return semanticIssue(`/redirect_uris/${index}`, "must use HTTPS (or loopback HTTP in local mode)");
|
|
400
|
+
}
|
|
401
|
+
const nativeAllowed = nativeRedirectMatchesApplication(url, String(manifest.id));
|
|
402
|
+
if (url.origin !== homepage.origin && !nativeAllowed) {
|
|
403
|
+
return semanticIssue(`/redirect_uris/${index}`, "must use the homepage origin or a private-use scheme matching the application ID");
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (manifest.icon !== undefined && new URL(String(manifest.icon)).origin !== homepage.origin) {
|
|
407
|
+
return semanticIssue("/icon", "must use the homepage origin");
|
|
408
|
+
}
|
|
409
|
+
return { valid: true, issues: [] };
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return semanticIssue("/", "contains an invalid URL");
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
function nativeRedirectMatchesApplication(url, applicationId) {
|
|
416
|
+
const scheme = url.protocol.slice(0, -1);
|
|
417
|
+
return isNativeRedirectUri(url)
|
|
418
|
+
&& (scheme === applicationId || scheme.startsWith(`${applicationId}.`));
|
|
419
|
+
}
|
|
420
|
+
function validateProvisionRequirements(value) {
|
|
421
|
+
const manifest = asObject(value);
|
|
422
|
+
const requirements = asObject(manifest.requirements);
|
|
423
|
+
const requiredContracts = Array.isArray(requirements.contracts) ? requirements.contracts : [];
|
|
424
|
+
const required = new Set(requiredContracts.map((contract) => {
|
|
425
|
+
const value = asObject(contract);
|
|
426
|
+
return `${value.id}@${value.version}`;
|
|
427
|
+
}));
|
|
428
|
+
const provisions = asObject(manifest.provisions);
|
|
429
|
+
const packs = Array.isArray(provisions.type_packs)
|
|
430
|
+
? provisions.type_packs
|
|
431
|
+
: [];
|
|
432
|
+
for (const [packIndex, provisionValue] of packs.entries()) {
|
|
433
|
+
const provision = asObject(provisionValue);
|
|
434
|
+
const providedContracts = Array.isArray(provision.provides)
|
|
435
|
+
? provision.provides
|
|
436
|
+
: [];
|
|
437
|
+
for (const providedValue of providedContracts) {
|
|
438
|
+
const provided = asObject(providedValue);
|
|
439
|
+
if (!required.has(`${provided.id}@${provided.version}`)) {
|
|
440
|
+
return semanticIssue(`/provisions/type_packs/${packIndex}/provides`, "may only contain contracts required by the application");
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const manifest = asObject(provision.manifest);
|
|
444
|
+
const declaredResources = Array.isArray(manifest.resources)
|
|
445
|
+
? manifest.resources.map(asObject)
|
|
446
|
+
: [];
|
|
447
|
+
const embeddedResources = Array.isArray(provision.resources)
|
|
448
|
+
? provision.resources.map(asObject)
|
|
449
|
+
: [];
|
|
450
|
+
const embedded = new Map(embeddedResources.map((resource) => [
|
|
451
|
+
String(resource.source),
|
|
452
|
+
resource.document
|
|
453
|
+
]));
|
|
454
|
+
if (embedded.size !== embeddedResources.length
|
|
455
|
+
|| declaredResources.length !== embeddedResources.length) {
|
|
456
|
+
return semanticIssue(`/provisions/type_packs/${packIndex}/resources`, "must match manifest source paths exactly");
|
|
457
|
+
}
|
|
458
|
+
for (const [resourceIndex, resource] of declaredResources.entries()) {
|
|
459
|
+
const source = String(resource.source);
|
|
460
|
+
const document = embedded.get(source);
|
|
461
|
+
if (typeof document !== "string") {
|
|
462
|
+
return semanticIssue(`/provisions/type_packs/${packIndex}/resources`, `is missing manifest source ${source}`);
|
|
463
|
+
}
|
|
464
|
+
const digest = `sha256:${createHash("sha256").update(document).digest("hex")}`;
|
|
465
|
+
if (digest !== resource.digest) {
|
|
466
|
+
return semanticIssue(`/provisions/type_packs/${packIndex}/manifest/resources/${resourceIndex}/digest`, "does not match the embedded document");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return { valid: true, issues: [] };
|
|
471
|
+
}
|
|
472
|
+
function localManifestSchemaCandidate(value) {
|
|
473
|
+
const candidate = clone(value);
|
|
474
|
+
const object = asObject(candidate);
|
|
475
|
+
for (const field of ["homepage", "icon"]) {
|
|
476
|
+
if (typeof object[field] === "string")
|
|
477
|
+
object[field] = schemaSafeLocalUrl(object[field]);
|
|
478
|
+
}
|
|
479
|
+
if (Array.isArray(object.redirect_uris)) {
|
|
480
|
+
object.redirect_uris = object.redirect_uris.map((url) => typeof url === "string" ? schemaSafeLocalUrl(url) : url);
|
|
481
|
+
}
|
|
482
|
+
return candidate;
|
|
483
|
+
}
|
|
484
|
+
function schemaSafeLocalUrl(value) {
|
|
485
|
+
try {
|
|
486
|
+
const url = new URL(value);
|
|
487
|
+
if (url.protocol === "http:" && isLoopback(url.hostname))
|
|
488
|
+
url.protocol = "https:";
|
|
489
|
+
return url.href;
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return value;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function secureOrAllowedLocal(url, allowLocal) {
|
|
496
|
+
return url.protocol === "https:"
|
|
497
|
+
|| (allowLocal && url.protocol === "http:" && isLoopback(url.hostname));
|
|
498
|
+
}
|
|
499
|
+
function isLoopback(hostname) {
|
|
500
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
501
|
+
}
|
|
502
|
+
function semanticIssue(path, message) {
|
|
503
|
+
return {
|
|
504
|
+
valid: false,
|
|
505
|
+
issues: [{ path, keyword: "semantic", message, params: {} }]
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function sandboxDescription(value = {}) {
|
|
509
|
+
return {
|
|
510
|
+
protocol_version: 1,
|
|
511
|
+
collection_id: value.collection_id ?? "01900000-0000-7000-8000-000000000001",
|
|
512
|
+
display_name: value.display_name ?? "Developer sandbox",
|
|
513
|
+
spec_version: value.spec_version ?? "0.3.0",
|
|
514
|
+
operations: value.operations ?? ["describe", "changes", "read", "query", "list_views", "execute_view", "read_view_source", "validate", "create", "update", "delete", "rename", "create_view_source", "update_view_source", "delete_view_source", "read_type", "create_type", "update_type", "install_type_pack"],
|
|
515
|
+
change_cursor: value.change_cursor ?? 0,
|
|
516
|
+
types: clone(value.types ?? []),
|
|
517
|
+
contracts: clone(value.contracts ?? [])
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
function envelope(result) {
|
|
521
|
+
return { valid: true, result: result, diagnostics: [] };
|
|
522
|
+
}
|
|
523
|
+
function invalid(code, message, path) {
|
|
524
|
+
return {
|
|
525
|
+
valid: false,
|
|
526
|
+
result: {},
|
|
527
|
+
diagnostics: [{ severity: "error", code, message, path }]
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function checkRevision(input, record) {
|
|
531
|
+
if (input.if_revision === undefined || input.if_revision === record.revision)
|
|
532
|
+
return null;
|
|
533
|
+
return invalid("concurrent_modification", `File does not match the requested revision: ${record.path}`, record.path);
|
|
534
|
+
}
|
|
535
|
+
function explicitTypes(frontmatter) {
|
|
536
|
+
const values = [frontmatter.type, frontmatter.types]
|
|
537
|
+
.flatMap((value) => Array.isArray(value) ? value : [value])
|
|
538
|
+
.filter((value) => typeof value === "string" && value.length > 0)
|
|
539
|
+
.map((value) => value.toLowerCase());
|
|
540
|
+
return [...new Set(values)];
|
|
541
|
+
}
|
|
542
|
+
function assertSafePath(path) {
|
|
543
|
+
if (!path || path.startsWith("/") || path.includes("\\") || path.split("/").includes("..")) {
|
|
544
|
+
throw new MdbaseConnectError("invalid_path", `Unsafe sandbox path: ${path}`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
function asObject(value) {
|
|
548
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
549
|
+
? value
|
|
550
|
+
: {};
|
|
551
|
+
}
|
|
552
|
+
function string(value, field) {
|
|
553
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
554
|
+
throw new MdbaseConnectError("invalid_request", `${field} must be a non-empty string.`);
|
|
555
|
+
}
|
|
556
|
+
return value;
|
|
557
|
+
}
|
|
558
|
+
function integer(value, field, minimum) {
|
|
559
|
+
if (!Number.isInteger(value) || Number(value) < minimum) {
|
|
560
|
+
throw new MdbaseConnectError("invalid_request", `${field} must be an integer of at least ${minimum}.`);
|
|
561
|
+
}
|
|
562
|
+
return Number(value);
|
|
563
|
+
}
|
|
564
|
+
function clone(value) {
|
|
565
|
+
return structuredClone(value);
|
|
566
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mdbase-dev/connect-dev",
|
|
3
|
+
"version": "0.1.0-beta.21",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mdbase-dev/mdbase-connect.git",
|
|
10
|
+
"directory": "packages/devkit"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://mdbase.dev",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"mdbase-connect-dev": "./dist/cli.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"ajv": "^8.20.0",
|
|
28
|
+
"@mdbase-dev/connect": "0.1.0-beta.21",
|
|
29
|
+
"@mdbase-dev/connect-protocol": "0.1.0-beta.21"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^24.0.0",
|
|
33
|
+
"typescript": "^7.0.2",
|
|
34
|
+
"vitest": "^4.1.10"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
40
|
+
}
|
|
41
|
+
}
|