@lapis-notes/api 0.1.0 → 0.1.3
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/CHANGELOG.md +39 -0
- package/README.md +5 -0
- package/dist/app-database-host.d.ts +1 -0
- package/dist/app-database-host.js +1 -0
- package/dist/browser-sync-security.d.ts +45 -0
- package/dist/browser-sync-security.js +189 -0
- package/dist/build-tools/vite.d.ts +15 -0
- package/dist/build-tools/vite.js +36 -0
- package/dist/cache.svelte.d.ts +1 -1
- package/dist/cache.svelte.js +4 -1
- package/dist/components/editor/editor.css +54 -2
- package/dist/components/editor/extensions/lint/lapis-lint-tooltip.svelte +18 -5
- package/dist/components/editor/extensions/lint/lint-tooltip-actions.d.ts +0 -2
- package/dist/components/editor/extensions/lint/lint-tooltip-actions.js +0 -14
- package/dist/components/editor/language-service/index.js +23 -2
- package/dist/components/icon/icon-list.svelte.d.ts +1 -1
- package/dist/components/select/select.svelte.d.ts +1 -1
- package/dist/context.svelte.d.ts +11 -1
- package/dist/context.svelte.js +38 -1
- package/dist/docs-api.d.ts +2 -0
- package/dist/docs-api.js +1 -0
- package/dist/generated/plugin-host-modules.schema.generated.d.ts +443 -13
- package/dist/generated/plugin-host-modules.schema.generated.js +970 -306
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/language-service/manager.d.ts +5 -0
- package/dist/language-service/manager.js +58 -14
- package/dist/lapis-extension.d.ts +1 -1
- package/dist/lapis-extension.js +12 -0
- package/dist/nostr-signer.d.ts +68 -0
- package/dist/nostr-signer.js +49 -0
- package/dist/plugin-asset-server.d.ts +1 -0
- package/dist/plugin-asset-server.js +3 -0
- package/dist/plugin-capability-facade.d.ts +1 -1
- package/dist/plugin-capability-facade.js +6 -0
- package/dist/plugin-distribution/catalog-media.d.ts +4 -0
- package/dist/plugin-distribution/catalog-media.js +40 -0
- package/dist/plugin-distribution/download-stats.d.ts +4 -0
- package/dist/plugin-distribution/download-stats.js +19 -0
- package/dist/plugin-distribution/index.d.ts +4 -0
- package/dist/plugin-distribution/index.js +4 -0
- package/dist/plugin-distribution/installer.d.ts +3 -0
- package/dist/plugin-distribution/installer.js +26 -3
- package/dist/plugin-distribution/manager.d.ts +71 -5
- package/dist/plugin-distribution/manager.js +376 -37
- package/dist/plugin-distribution/nostr-registry-adapter.d.ts +29 -0
- package/dist/plugin-distribution/nostr-registry-adapter.js +152 -0
- package/dist/plugin-distribution/plugin-bundle-verifier.d.ts +43 -0
- package/dist/plugin-distribution/plugin-bundle-verifier.js +64 -1
- package/dist/plugin-distribution/plugin-bundle.d.ts +14 -0
- package/dist/plugin-distribution/plugin-bundle.js +46 -7
- package/dist/plugin-distribution/plugin-markdown.d.ts +6 -0
- package/dist/plugin-distribution/plugin-markdown.js +94 -0
- package/dist/plugin-distribution/registry-client.d.ts +6 -1
- package/dist/plugin-distribution/registry-client.js +32 -10
- package/dist/plugin-distribution/registry-source-state.d.ts +15 -0
- package/dist/plugin-distribution/registry-source-state.js +42 -0
- package/dist/plugin-distribution/schemas.d.ts +2511 -2824
- package/dist/plugin-distribution/schemas.js +111 -42
- package/dist/plugin-distribution/types.d.ts +116 -5
- package/dist/plugin-distribution-host.d.ts +4 -0
- package/dist/plugin-distribution-host.js +2 -0
- package/dist/plugin-install-progress.js +1 -1
- package/dist/plugin-manager.d.ts +3 -0
- package/dist/plugin-manager.js +21 -13
- package/dist/plugin-settings.d.ts +21 -0
- package/dist/plugin-settings.js +41 -0
- package/dist/plugin.d.ts +13 -0
- package/dist/plugin.js +56 -0
- package/dist/registry-schema/catalog-v1.d.ts +4143 -0
- package/dist/registry-schema/catalog-v1.js +172 -0
- package/dist/registry-schema/catalog-v2.d.ts +5275 -0
- package/dist/registry-schema/catalog-v2.js +137 -0
- package/dist/registry-schema/index.d.ts +1 -0
- package/dist/registry-schema/index.js +1 -0
- package/dist/registry-schema/release-notes.d.ts +5 -0
- package/dist/registry-schema/release-notes.js +21 -0
- package/dist/server-host.d.ts +28 -0
- package/dist/server-host.js +17 -0
- package/dist/storage/app-database.d.ts +37 -2
- package/dist/storage/app-database.js +41 -1
- package/dist/storage/browser-coordinated-app-database.d.ts +3 -1
- package/dist/storage/browser-coordinated-app-database.js +8 -0
- package/dist/storage/browser.d.ts +4 -1
- package/dist/storage/browser.js +84 -0
- package/dist/storage/desktop-native.d.ts +4 -2
- package/dist/storage/desktop-native.js +90 -1
- package/dist/storage/fs.d.ts +17 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/memory-vault-adapter.d.ts +3 -1
- package/dist/storage/memory-vault-adapter.js +60 -0
- package/dist/storage/remote-app-database.d.ts +19 -0
- package/dist/storage/remote-app-database.js +130 -0
- package/dist/storage/task-projection.d.ts +2 -1
- package/dist/storage/task-projection.js +7 -2
- package/dist/storage/turso-app-database.d.ts +3 -1
- package/dist/storage/turso-app-database.js +45 -0
- package/dist/storage/vault-session.js +11 -0
- package/dist/storage/vault-state.d.ts +1 -1
- package/dist/storage/vault.svelte.d.ts +3 -1
- package/dist/storage/vault.svelte.js +76 -3
- package/dist/sync-host-security.d.ts +285 -0
- package/dist/sync-host-security.js +2237 -0
- package/dist/sync-host.d.ts +179 -0
- package/dist/sync-host.js +769 -0
- package/dist/workspace.svelte.d.ts +1 -1
- package/dist/workspace.svelte.js +1 -1
- package/package.json +48 -10
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { PluginDistributionError } from "./errors";
|
|
2
|
+
import { verifyNostrPluginPayload, } from "./plugin-bundle-verifier";
|
|
3
|
+
import { parsePluginDistributionMetadata, pluginCatalogDetailSchema, pluginCatalogIndexSchema, } from "./schemas";
|
|
4
|
+
export function createNostrPluginRegistryAdapter(options) {
|
|
5
|
+
const fetchImpl = options.fetch ?? ((url, init) => fetch(url, init));
|
|
6
|
+
return {
|
|
7
|
+
async refresh(source, refreshOptions) {
|
|
8
|
+
if (refreshOptions?.force) {
|
|
9
|
+
await options.catalogSource.refresh?.();
|
|
10
|
+
}
|
|
11
|
+
const index = parsePluginDistributionMetadata(pluginCatalogIndexSchema, normalizeNostrCatalogIndex(await options.catalogSource.loadIndex()));
|
|
12
|
+
const status = options.catalogSource.getStatus();
|
|
13
|
+
return {
|
|
14
|
+
index,
|
|
15
|
+
installReady: status.installReady,
|
|
16
|
+
stale: !status.fresh,
|
|
17
|
+
...(status.message ? { message: status.message } : {}),
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
async getDetail(_source, entry) {
|
|
21
|
+
return parsePluginDistributionMetadata(pluginCatalogDetailSchema, normalizeNostrCatalogDetail(await options.catalogSource.loadPluginDetail(entry)));
|
|
22
|
+
},
|
|
23
|
+
async getVerifiedBundle(source, entry, detail, version, installOptions) {
|
|
24
|
+
const curatorPubkeys = source.curatorPubkeys ?? [];
|
|
25
|
+
if (curatorPubkeys.length === 0) {
|
|
26
|
+
throw new PluginDistributionError("metadata-invalid", `Nostr registry ${source.name} has no pinned curator keys.`);
|
|
27
|
+
}
|
|
28
|
+
const release = requireRelease(detail, version);
|
|
29
|
+
const artifactUrl = resolveArtifactUrl(source, release.bundle);
|
|
30
|
+
const response = await fetchImpl(artifactUrl, {
|
|
31
|
+
signal: installOptions?.signal,
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
throw new PluginDistributionError("metadata-invalid", `Plugin payload fetch failed for ${artifactUrl}: ${response.status} ${response.statusText}`);
|
|
35
|
+
}
|
|
36
|
+
const [artifact, proof] = await Promise.all([
|
|
37
|
+
response.arrayBuffer().then((value) => new Uint8Array(value)),
|
|
38
|
+
options.catalogSource.loadReleaseProof(entry.id, version, {
|
|
39
|
+
signal: installOptions?.signal,
|
|
40
|
+
}),
|
|
41
|
+
]);
|
|
42
|
+
return verifyNostrPluginPayload({
|
|
43
|
+
artifact,
|
|
44
|
+
proof,
|
|
45
|
+
trustedCuratorPubkeys: new Set(curatorPubkeys),
|
|
46
|
+
verifyReleaseProof: options.verifyReleaseProof,
|
|
47
|
+
signal: installOptions?.signal,
|
|
48
|
+
relayFresh: true,
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
async verifyOfflineBundle(source, bundle, installOptions) {
|
|
52
|
+
const curatorPubkeys = source.curatorPubkeys ?? [];
|
|
53
|
+
if (curatorPubkeys.length === 0) {
|
|
54
|
+
throw new PluginDistributionError("metadata-invalid", `Nostr registry ${source.name} has no pinned curator keys.`);
|
|
55
|
+
}
|
|
56
|
+
return verifyNostrPluginPayload({
|
|
57
|
+
artifact: bundle.payload,
|
|
58
|
+
proof: bundle.proof,
|
|
59
|
+
trustedCuratorPubkeys: new Set(curatorPubkeys),
|
|
60
|
+
verifyReleaseProof: options.verifyReleaseProof,
|
|
61
|
+
signal: installOptions?.signal,
|
|
62
|
+
relayFresh: false,
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
async revalidateInstalledProof(source, record, installOptions) {
|
|
66
|
+
const stored = requireStoredProof(record);
|
|
67
|
+
const curatorPubkeys = source.curatorPubkeys ?? [];
|
|
68
|
+
if (curatorPubkeys.length === 0) {
|
|
69
|
+
throw new PluginDistributionError("metadata-invalid", `Nostr registry ${source.name} has no pinned curator keys.`);
|
|
70
|
+
}
|
|
71
|
+
const status = options.catalogSource.getStatus();
|
|
72
|
+
if (!status.installReady || !status.fresh) {
|
|
73
|
+
throw new PluginDistributionError("metadata-invalid", `Nostr registry ${source.name} has not reached install quorum.`);
|
|
74
|
+
}
|
|
75
|
+
const proof = await options.catalogSource.loadReleaseProof(record.pluginId, record.installedVersion, { signal: installOptions?.signal });
|
|
76
|
+
const verified = await options.verifyReleaseProof(proof, {
|
|
77
|
+
artifact: {
|
|
78
|
+
sha256: stored.artifactSha256,
|
|
79
|
+
size: stored.artifactSize,
|
|
80
|
+
},
|
|
81
|
+
trustedCuratorPubkeys: new Set(curatorPubkeys),
|
|
82
|
+
});
|
|
83
|
+
if (verified.releaseEventId !== stored.releaseEventId) {
|
|
84
|
+
throw new PluginDistributionError("metadata-invalid", "Live Nostr proof names a different immutable release event.");
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
authorizationEventId: verified.authorizationEventId,
|
|
88
|
+
releaseEventId: verified.releaseEventId,
|
|
89
|
+
decisionEventId: verified.decisionEventId,
|
|
90
|
+
manifestSha256: verified.manifestSha256,
|
|
91
|
+
artifactSha256: verified.artifactSha256,
|
|
92
|
+
artifactSize: verified.artifactSize,
|
|
93
|
+
requiresRelayRefresh: false,
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function normalizeNostrCatalogIndex(value) {
|
|
99
|
+
if (!isRecord(value) || !Array.isArray(value.plugins))
|
|
100
|
+
return value;
|
|
101
|
+
return {
|
|
102
|
+
...value,
|
|
103
|
+
plugins: value.plugins.map((entry) => removeNonHttpsReadmeUrl(entry)),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function normalizeNostrCatalogDetail(value) {
|
|
107
|
+
return removeNonHttpsReadmeUrl(value);
|
|
108
|
+
}
|
|
109
|
+
function removeNonHttpsReadmeUrl(value) {
|
|
110
|
+
if (!isRecord(value))
|
|
111
|
+
return value;
|
|
112
|
+
const readmeUrl = value.readmeUrl;
|
|
113
|
+
if (readmeUrl === undefined || isHttpsUrl(readmeUrl))
|
|
114
|
+
return value;
|
|
115
|
+
const { readmeUrl: _readmeUrl, ...rest } = value;
|
|
116
|
+
return rest;
|
|
117
|
+
}
|
|
118
|
+
function isHttpsUrl(value) {
|
|
119
|
+
if (typeof value !== "string")
|
|
120
|
+
return false;
|
|
121
|
+
try {
|
|
122
|
+
return new URL(value).protocol === "https:";
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function isRecord(value) {
|
|
129
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
130
|
+
}
|
|
131
|
+
function requireStoredProof(record) {
|
|
132
|
+
if (!record.nostrProof) {
|
|
133
|
+
throw new PluginDistributionError("metadata-invalid", "Installed plugin has no stored Nostr proof.");
|
|
134
|
+
}
|
|
135
|
+
return record.nostrProof;
|
|
136
|
+
}
|
|
137
|
+
function requireRelease(detail, version) {
|
|
138
|
+
const release = detail.versions[version];
|
|
139
|
+
if (!release) {
|
|
140
|
+
throw new PluginDistributionError("metadata-invalid", `${detail.id} has no Nostr release ${version}.`);
|
|
141
|
+
}
|
|
142
|
+
return release;
|
|
143
|
+
}
|
|
144
|
+
function resolveArtifactUrl(source, bundle) {
|
|
145
|
+
const location = bundle.downloadUrl ?? bundle.url;
|
|
146
|
+
try {
|
|
147
|
+
return new URL(location).toString();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return new URL(location, source.url).toString();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -21,8 +21,51 @@ export interface VerifiedPluginBundlePayload {
|
|
|
21
21
|
releaseManifest: PluginReleaseManifest;
|
|
22
22
|
releaseManifestSha256: string;
|
|
23
23
|
files: Map<string, Uint8Array>;
|
|
24
|
+
nostrProof?: {
|
|
25
|
+
authorizationEventId: string;
|
|
26
|
+
releaseEventId: string;
|
|
27
|
+
decisionEventId: string;
|
|
28
|
+
manifestSha256: string;
|
|
29
|
+
artifactSha256: string;
|
|
30
|
+
artifactSize: number;
|
|
31
|
+
requiresRelayRefresh: boolean;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export interface VerifiedNostrReleaseProofResult {
|
|
35
|
+
pluginId: string;
|
|
36
|
+
version: string;
|
|
37
|
+
authorizationEventId: string;
|
|
38
|
+
releaseEventId: string;
|
|
39
|
+
decisionEventId: string;
|
|
40
|
+
manifestSha256: string;
|
|
41
|
+
artifactSha256: string;
|
|
42
|
+
artifactSize: number;
|
|
43
|
+
requiresRelayRefresh: true;
|
|
44
|
+
manifest: {
|
|
45
|
+
release: {
|
|
46
|
+
metadata: Record<string, unknown>;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export type NostrReleaseProofVerifier = (proof: unknown, options: {
|
|
51
|
+
artifact: Uint8Array | {
|
|
52
|
+
sha256: string;
|
|
53
|
+
size: number;
|
|
54
|
+
};
|
|
55
|
+
trustedCuratorPubkeys: ReadonlySet<string>;
|
|
56
|
+
}) => VerifiedNostrReleaseProofResult | Promise<VerifiedNostrReleaseProofResult>;
|
|
57
|
+
export interface VerifyNostrPluginPayloadOptions {
|
|
58
|
+
artifact: ArrayBuffer | Uint8Array;
|
|
59
|
+
proof: unknown;
|
|
60
|
+
trustedCuratorPubkeys: ReadonlySet<string>;
|
|
61
|
+
verifyReleaseProof: NostrReleaseProofVerifier;
|
|
62
|
+
signal?: AbortSignal;
|
|
63
|
+
onProgress?: (event: PluginBundleVerificationProgressEvent) => void;
|
|
64
|
+
/** True only when a live source reached its configured install quorum. */
|
|
65
|
+
relayFresh?: boolean;
|
|
24
66
|
}
|
|
25
67
|
export declare const verifyPluginBundlePayload: (options: VerifyPluginBundlePayloadOptions) => Promise<VerifiedPluginBundlePayload>;
|
|
68
|
+
export declare const verifyNostrPluginPayload: (options: VerifyNostrPluginPayloadOptions) => Promise<VerifiedPluginBundlePayload>;
|
|
26
69
|
export declare function throwIfAborted(signal: AbortSignal | undefined): void;
|
|
27
70
|
export declare function createAbortError(): Error;
|
|
28
71
|
export declare function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { sha256Hex, verifySha256 } from "./hashes";
|
|
2
2
|
import { assertSafePluginRelativePath } from "./path-safety";
|
|
3
|
-
import { PLUGIN_BUNDLE_RELEASE_MANIFEST_PATH, parsePluginBundle, } from "./plugin-bundle";
|
|
3
|
+
import { PLUGIN_BUNDLE_RELEASE_MANIFEST_PATH, parsePluginBundle, parsePluginPayload, } from "./plugin-bundle";
|
|
4
4
|
import { parsePluginDistributionMetadata, pluginReleaseManifestSchema, signedEnvelopeSchema, } from "./schemas";
|
|
5
5
|
import { verifySignedEnvelope } from "./signing";
|
|
6
6
|
import { PluginDistributionError } from "./errors";
|
|
@@ -74,6 +74,69 @@ export const verifyPluginBundlePayload = async (options) => {
|
|
|
74
74
|
files,
|
|
75
75
|
};
|
|
76
76
|
};
|
|
77
|
+
export const verifyNostrPluginPayload = async (options) => {
|
|
78
|
+
throwIfAborted(options.signal);
|
|
79
|
+
const artifact = toUint8(options.artifact);
|
|
80
|
+
options.onProgress?.({
|
|
81
|
+
phase: "verifying-bundle",
|
|
82
|
+
message: "Verifying Nostr release proof",
|
|
83
|
+
});
|
|
84
|
+
const verified = await options.verifyReleaseProof(options.proof, {
|
|
85
|
+
artifact,
|
|
86
|
+
trustedCuratorPubkeys: options.trustedCuratorPubkeys,
|
|
87
|
+
});
|
|
88
|
+
const releaseManifest = parsePluginDistributionMetadata(pluginReleaseManifestSchema, verified.manifest.release.metadata.lapisRelease);
|
|
89
|
+
if (releaseManifest.pluginId !== verified.pluginId ||
|
|
90
|
+
releaseManifest.version !== verified.version) {
|
|
91
|
+
throw new PluginDistributionError("metadata-invalid", "Nostr proof release coordinates do not match the plugin payload manifest.");
|
|
92
|
+
}
|
|
93
|
+
const bundledFiles = parsePluginPayload(artifact, {
|
|
94
|
+
onProgress: options.onProgress,
|
|
95
|
+
});
|
|
96
|
+
const files = new Map();
|
|
97
|
+
const expectedPaths = new Set();
|
|
98
|
+
const fileCount = releaseManifest.files.length;
|
|
99
|
+
for (const [index, file] of releaseManifest.files.entries()) {
|
|
100
|
+
throwIfAborted(options.signal);
|
|
101
|
+
assertSafePluginRelativePath(file.path);
|
|
102
|
+
expectedPaths.add(file.path);
|
|
103
|
+
const bytes = bundledFiles.get(file.path);
|
|
104
|
+
if (!bytes) {
|
|
105
|
+
throw new PluginDistributionError("metadata-invalid", `Plugin payload is missing curated file: ${file.path}`);
|
|
106
|
+
}
|
|
107
|
+
if (bytes.byteLength !== file.size) {
|
|
108
|
+
throw new PluginDistributionError("hash-mismatch", `Plugin file ${file.path} size mismatch: expected ${file.size}, got ${bytes.byteLength}.`);
|
|
109
|
+
}
|
|
110
|
+
options.onProgress?.({
|
|
111
|
+
phase: "verifying-files",
|
|
112
|
+
message: `Verifying ${file.path}`,
|
|
113
|
+
filePath: file.path,
|
|
114
|
+
fileIndex: index + 1,
|
|
115
|
+
fileCount,
|
|
116
|
+
});
|
|
117
|
+
await verifySha256(bytes, file.sha256);
|
|
118
|
+
files.set(file.path, bytes);
|
|
119
|
+
}
|
|
120
|
+
const extraPaths = [...bundledFiles.keys()].filter((path) => !expectedPaths.has(path));
|
|
121
|
+
if (extraPaths.length) {
|
|
122
|
+
throw new PluginDistributionError("metadata-invalid", "Plugin payload includes files that are not listed in the curated release manifest.", { details: { paths: extraPaths } });
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
releaseEnvelope: { signed: releaseManifest, signatures: [] },
|
|
126
|
+
releaseManifest,
|
|
127
|
+
releaseManifestSha256: verified.manifestSha256,
|
|
128
|
+
files,
|
|
129
|
+
nostrProof: {
|
|
130
|
+
authorizationEventId: verified.authorizationEventId,
|
|
131
|
+
releaseEventId: verified.releaseEventId,
|
|
132
|
+
decisionEventId: verified.decisionEventId,
|
|
133
|
+
manifestSha256: verified.manifestSha256,
|
|
134
|
+
artifactSha256: verified.artifactSha256,
|
|
135
|
+
artifactSize: verified.artifactSize,
|
|
136
|
+
requiresRelayRefresh: options.relayFresh !== true,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
};
|
|
77
140
|
export function throwIfAborted(signal) {
|
|
78
141
|
if (!signal?.aborted) {
|
|
79
142
|
return;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export declare const PLUGIN_BUNDLE_RELEASE_MANIFEST_PATH = "release.signed.json";
|
|
2
|
+
export declare const NOSTR_PLUGIN_BUNDLE_PROOF_PATH = "release.nostr.json";
|
|
3
|
+
export declare const NOSTR_PLUGIN_BUNDLE_PAYLOAD_PATH = "payload.zip";
|
|
2
4
|
export declare const PLUGIN_BUNDLE_STORED_METHOD = 0;
|
|
3
5
|
export declare const PLUGIN_BUNDLE_DEFLATE_METHOD = 8;
|
|
4
6
|
export interface ParsePluginBundleProgressEvent {
|
|
@@ -14,3 +16,15 @@ export interface ParsePluginBundleOptions {
|
|
|
14
16
|
onProgress?: (event: ParsePluginBundleProgressEvent) => void;
|
|
15
17
|
}
|
|
16
18
|
export declare const parsePluginBundle: (input: ArrayBuffer | Uint8Array, options?: ParsePluginBundleOptions) => Map<string, Uint8Array>;
|
|
19
|
+
/** Parse the deterministic inner payload used by Nostr proof bundles. */
|
|
20
|
+
export declare const parsePluginPayload: (input: ArrayBuffer | Uint8Array, options?: ParsePluginBundleOptions) => Map<string, Uint8Array>;
|
|
21
|
+
export interface ParsedNostrPluginBundle {
|
|
22
|
+
proof: unknown;
|
|
23
|
+
payload: Uint8Array;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Parse the portable offline Nostr container, or return null for a legacy
|
|
27
|
+
* root-signed bundle. Once either v2 marker is present, malformed containers
|
|
28
|
+
* fail closed instead of falling back to the legacy verifier.
|
|
29
|
+
*/
|
|
30
|
+
export declare const parseNostrPluginBundle: (input: ArrayBuffer | Uint8Array, options?: ParsePluginBundleOptions) => ParsedNostrPluginBundle | null;
|
|
@@ -2,11 +2,46 @@ import { Unzip, UnzipInflate } from "fflate";
|
|
|
2
2
|
import { PluginDistributionError } from "./errors";
|
|
3
3
|
import { assertSafePluginRelativePath } from "./path-safety";
|
|
4
4
|
export const PLUGIN_BUNDLE_RELEASE_MANIFEST_PATH = "release.signed.json";
|
|
5
|
+
export const NOSTR_PLUGIN_BUNDLE_PROOF_PATH = "release.nostr.json";
|
|
6
|
+
export const NOSTR_PLUGIN_BUNDLE_PAYLOAD_PATH = "payload.zip";
|
|
5
7
|
export const PLUGIN_BUNDLE_STORED_METHOD = 0;
|
|
6
8
|
export const PLUGIN_BUNDLE_DEFLATE_METHOD = 8;
|
|
7
|
-
export const parsePluginBundle = (input, options = {}) =>
|
|
9
|
+
export const parsePluginBundle = (input, options = {}) => parsePluginArchive(input, options, true);
|
|
10
|
+
/** Parse the deterministic inner payload used by Nostr proof bundles. */
|
|
11
|
+
export const parsePluginPayload = (input, options = {}) => parsePluginArchive(input, options, false);
|
|
12
|
+
/**
|
|
13
|
+
* Parse the portable offline Nostr container, or return null for a legacy
|
|
14
|
+
* root-signed bundle. Once either v2 marker is present, malformed containers
|
|
15
|
+
* fail closed instead of falling back to the legacy verifier.
|
|
16
|
+
*/
|
|
17
|
+
export const parseNostrPluginBundle = (input, options = {}) => {
|
|
18
|
+
const entries = parsePluginArchive(input, options, false);
|
|
19
|
+
const hasNostrMarker = entries.has(NOSTR_PLUGIN_BUNDLE_PROOF_PATH) ||
|
|
20
|
+
entries.has(NOSTR_PLUGIN_BUNDLE_PAYLOAD_PATH);
|
|
21
|
+
if (!hasNostrMarker)
|
|
22
|
+
return null;
|
|
23
|
+
const paths = [...entries.keys()];
|
|
24
|
+
if (paths.length !== 2 ||
|
|
25
|
+
paths[0] !== NOSTR_PLUGIN_BUNDLE_PROOF_PATH ||
|
|
26
|
+
paths[1] !== NOSTR_PLUGIN_BUNDLE_PAYLOAD_PATH) {
|
|
27
|
+
throw invalidBundle(".lapis-plugin Nostr bundle must contain only release.nostr.json and payload.zip.");
|
|
28
|
+
}
|
|
29
|
+
const proofBytes = entries.get(NOSTR_PLUGIN_BUNDLE_PROOF_PATH);
|
|
30
|
+
let proof;
|
|
31
|
+
try {
|
|
32
|
+
proof = JSON.parse(new TextDecoder().decode(proofBytes));
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw invalidBundle(".lapis-plugin release.nostr.json is invalid JSON.", error);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
proof,
|
|
39
|
+
payload: entries.get(NOSTR_PLUGIN_BUNDLE_PAYLOAD_PATH),
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
const parsePluginArchive = (input, options, requireSignedManifest) => {
|
|
8
43
|
const bytes = toUint8(input);
|
|
9
|
-
const expectedEntries = inspectPluginBundleMetadata(bytes);
|
|
44
|
+
const expectedEntries = inspectPluginBundleMetadata(bytes, requireSignedManifest);
|
|
10
45
|
const entries = new Map();
|
|
11
46
|
let seenFileCount = 0;
|
|
12
47
|
let processedBytes = 0;
|
|
@@ -82,12 +117,13 @@ export const parsePluginBundle = (input, options = {}) => {
|
|
|
82
117
|
throw invalidBundle(`.lapis-plugin bundle did not extract ${expectedEntry.path}.`);
|
|
83
118
|
}
|
|
84
119
|
}
|
|
85
|
-
if (
|
|
120
|
+
if (requireSignedManifest &&
|
|
121
|
+
!entries.has(PLUGIN_BUNDLE_RELEASE_MANIFEST_PATH)) {
|
|
86
122
|
throw invalidBundle(".lapis-plugin bundle is missing release.signed.json.");
|
|
87
123
|
}
|
|
88
124
|
return entries;
|
|
89
125
|
};
|
|
90
|
-
const inspectPluginBundleMetadata = (bytes) => {
|
|
126
|
+
const inspectPluginBundleMetadata = (bytes, requireSignedManifest) => {
|
|
91
127
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
92
128
|
const eocdOffset = findEndOfCentralDirectory(view);
|
|
93
129
|
const totalEntries = readUint16(view, eocdOffset + 10);
|
|
@@ -155,13 +191,16 @@ const inspectPluginBundleMetadata = (bytes) => {
|
|
|
155
191
|
}
|
|
156
192
|
const localOrder = [...entries].sort((left, right) => left.localHeaderOffset - right.localHeaderOffset);
|
|
157
193
|
const firstEntry = localOrder[0];
|
|
158
|
-
if (
|
|
194
|
+
if (requireSignedManifest &&
|
|
195
|
+
firstEntry?.path !== PLUGIN_BUNDLE_RELEASE_MANIFEST_PATH) {
|
|
159
196
|
throw invalidBundle(".lapis-plugin bundle must store release.signed.json as the first entry.");
|
|
160
197
|
}
|
|
161
|
-
if (
|
|
198
|
+
if (requireSignedManifest &&
|
|
199
|
+
firstEntry.compressionMethod !== PLUGIN_BUNDLE_STORED_METHOD) {
|
|
162
200
|
throw invalidBundle(".lapis-plugin release.signed.json entry must be stored.");
|
|
163
201
|
}
|
|
164
|
-
if (
|
|
202
|
+
if (requireSignedManifest &&
|
|
203
|
+
!seenPaths.has(PLUGIN_BUNDLE_RELEASE_MANIFEST_PATH)) {
|
|
165
204
|
throw invalidBundle(".lapis-plugin bundle is missing release.signed.json.");
|
|
166
205
|
}
|
|
167
206
|
return localOrder;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PluginMarkdownReference } from "./types";
|
|
2
|
+
export declare const MAX_PLUGIN_MARKDOWN_BYTES: number;
|
|
3
|
+
type FetchLike = typeof fetch;
|
|
4
|
+
export declare function clearVerifiedPluginMarkdownCache(): void;
|
|
5
|
+
export declare function fetchVerifiedPluginMarkdown(reference: PluginMarkdownReference, fetchImpl?: FetchLike): Promise<string>;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { PluginDistributionError } from "./errors";
|
|
2
|
+
import { verifySha256 } from "./hashes";
|
|
3
|
+
export const MAX_PLUGIN_MARKDOWN_BYTES = 256 * 1024;
|
|
4
|
+
const markdownCache = new Map();
|
|
5
|
+
export function clearVerifiedPluginMarkdownCache() {
|
|
6
|
+
markdownCache.clear();
|
|
7
|
+
}
|
|
8
|
+
export async function fetchVerifiedPluginMarkdown(reference, fetchImpl = fetch) {
|
|
9
|
+
validateReference(reference);
|
|
10
|
+
const cacheKey = referenceCacheKey(reference);
|
|
11
|
+
let pending = markdownCache.get(cacheKey);
|
|
12
|
+
if (!pending) {
|
|
13
|
+
pending = fetchAndVerify(reference, fetchImpl).catch((error) => {
|
|
14
|
+
markdownCache.delete(cacheKey);
|
|
15
|
+
throw error;
|
|
16
|
+
});
|
|
17
|
+
markdownCache.set(cacheKey, pending);
|
|
18
|
+
}
|
|
19
|
+
return pending;
|
|
20
|
+
}
|
|
21
|
+
function validateReference(reference) {
|
|
22
|
+
if (reference.mediaType !== "text/markdown" ||
|
|
23
|
+
!Number.isInteger(reference.size) ||
|
|
24
|
+
reference.size < 0 ||
|
|
25
|
+
reference.size > MAX_PLUGIN_MARKDOWN_BYTES ||
|
|
26
|
+
!/^[a-fA-F0-9]{64}$/.test(reference.sha256)) {
|
|
27
|
+
throw new PluginDistributionError("metadata-invalid", "Plugin Markdown reference is invalid or exceeds the supported size.", { details: { url: reference.url, size: reference.size } });
|
|
28
|
+
}
|
|
29
|
+
for (const [label, value] of [
|
|
30
|
+
["registry", reference.url],
|
|
31
|
+
["source", reference.sourceUrl],
|
|
32
|
+
]) {
|
|
33
|
+
try {
|
|
34
|
+
if (new URL(value).protocol !== "https:")
|
|
35
|
+
throw new Error("not HTTPS");
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw new PluginDistributionError("metadata-invalid", `Plugin Markdown ${label} URL must use HTTPS.`, { cause: error, details: { url: value } });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function fetchAndVerify(reference, fetchImpl) {
|
|
43
|
+
let response;
|
|
44
|
+
try {
|
|
45
|
+
response = await fetchImpl(reference.url, {
|
|
46
|
+
headers: { Accept: "text/markdown" },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new PluginDistributionError("metadata-invalid", "Plugin Markdown request failed.", { cause: error, details: { url: reference.url } });
|
|
51
|
+
}
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
throw new PluginDistributionError("metadata-invalid", `Plugin Markdown request failed with HTTP ${response.status}.`, { details: { url: reference.url, status: response.status } });
|
|
54
|
+
}
|
|
55
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
56
|
+
if (Number.isFinite(declaredLength) &&
|
|
57
|
+
(declaredLength !== reference.size ||
|
|
58
|
+
declaredLength > MAX_PLUGIN_MARKDOWN_BYTES)) {
|
|
59
|
+
throw new PluginDistributionError("metadata-invalid", "Plugin Markdown response size does not match signed metadata.", {
|
|
60
|
+
details: {
|
|
61
|
+
url: reference.url,
|
|
62
|
+
expectedSize: reference.size,
|
|
63
|
+
actualSize: declaredLength,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
68
|
+
if (bytes.byteLength !== reference.size ||
|
|
69
|
+
bytes.byteLength > MAX_PLUGIN_MARKDOWN_BYTES) {
|
|
70
|
+
throw new PluginDistributionError("metadata-invalid", "Plugin Markdown bytes do not match the signed size.", {
|
|
71
|
+
details: {
|
|
72
|
+
url: reference.url,
|
|
73
|
+
expectedSize: reference.size,
|
|
74
|
+
actualSize: bytes.byteLength,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
await verifySha256(bytes, reference.sha256);
|
|
79
|
+
try {
|
|
80
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
throw new PluginDistributionError("metadata-invalid", "Plugin Markdown is not valid UTF-8.", { cause: error, details: { url: reference.url } });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function referenceCacheKey(reference) {
|
|
87
|
+
return [
|
|
88
|
+
reference.url,
|
|
89
|
+
reference.sourceUrl,
|
|
90
|
+
reference.sha256.toLowerCase(),
|
|
91
|
+
reference.size,
|
|
92
|
+
reference.mediaType,
|
|
93
|
+
].join("\u0000");
|
|
94
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PluginCatalogDetail, PluginCatalogEntry, PluginCatalogIndex, PluginRegistrySource, PluginRevocationIndex, SignatureRecord, TrustedSigningKey } from "./types";
|
|
1
|
+
import type { PluginCatalogDetail, PluginCatalogEntry, PluginCatalogIndex, PluginDownloadStatsSummary, PluginRegistrySource, PluginRevocationIndex, SignatureRecord, TrustedSigningKey } from "./types";
|
|
2
2
|
import { PluginRegistryCache } from "./registry-cache";
|
|
3
3
|
export type PluginRegistryFetch = (url: string, init?: RequestInit) => Promise<Pick<Response, "ok" | "status" | "statusText" | "text">>;
|
|
4
4
|
export interface PluginRegistryRefreshResult {
|
|
@@ -24,10 +24,15 @@ export declare class PluginRegistryClient {
|
|
|
24
24
|
refresh(source: PluginRegistrySource): Promise<PluginRegistryRefreshResult>;
|
|
25
25
|
getDetail(entry: PluginCatalogEntry): Promise<PluginRegistryDetailResult>;
|
|
26
26
|
getCachedIndex(): PluginCatalogIndex | null;
|
|
27
|
+
getDownloadStats(source: PluginRegistrySource, options?: {
|
|
28
|
+
force?: boolean;
|
|
29
|
+
now?: Date;
|
|
30
|
+
}): Promise<PluginDownloadStatsSummary | null>;
|
|
27
31
|
private fetchJson;
|
|
28
32
|
private verifyIndex;
|
|
29
33
|
private fetchRevocations;
|
|
30
34
|
private verifyDetail;
|
|
35
|
+
private verifyOriginal;
|
|
31
36
|
private normalizeEntry;
|
|
32
37
|
private trustedKeysFor;
|
|
33
38
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { PluginDistributionError } from "./errors";
|
|
2
2
|
import { assertPluginIdAllowedForProvenance } from "./reserved-ids";
|
|
3
|
-
import { pluginCatalogDetailSchema, pluginCatalogIndexSchema, pluginRevocationIndexSchema, parsePluginDistributionMetadata, } from "./schemas";
|
|
3
|
+
import { pluginDownloadStatsSummarySchema, pluginCatalogDetailSchema, pluginCatalogIndexSchema, pluginRevocationIndexSchema, parsePluginDistributionMetadata, } from "./schemas";
|
|
4
|
+
import { isUsablePluginDownloadStatsSummary } from "./download-stats";
|
|
4
5
|
import { verifySignedEnvelope } from "./signing";
|
|
5
6
|
import { PluginRegistryCache } from "./registry-cache";
|
|
6
7
|
export class PluginRegistryClient {
|
|
@@ -56,16 +57,33 @@ export class PluginRegistryClient {
|
|
|
56
57
|
getCachedIndex() {
|
|
57
58
|
return this.currentIndex ?? this.cache.getMemory()?.index ?? null;
|
|
58
59
|
}
|
|
59
|
-
async
|
|
60
|
-
|
|
60
|
+
async getDownloadStats(source, options = {}) {
|
|
61
|
+
if (!source.downloadStatsUrl)
|
|
62
|
+
return null;
|
|
63
|
+
try {
|
|
64
|
+
const metadata = await this.fetchJson(new URL(source.downloadStatsUrl, source.url).toString(), {
|
|
65
|
+
cache: options.force ? "reload" : "no-cache",
|
|
66
|
+
headers: { accept: "application/json" },
|
|
67
|
+
});
|
|
68
|
+
const summary = parsePluginDistributionMetadata(pluginDownloadStatsSummarySchema, metadata);
|
|
69
|
+
return isUsablePluginDownloadStatsSummary(summary, options.now ?? new Date())
|
|
70
|
+
? summary
|
|
71
|
+
: null;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async fetchJson(url, init) {
|
|
78
|
+
const response = await this.fetchImpl(url, init);
|
|
61
79
|
if (!response.ok) {
|
|
62
80
|
throw new PluginDistributionError("metadata-invalid", `Registry fetch failed for ${url}: ${response.status} ${response.statusText}`, { details: { url, status: response.status } });
|
|
63
81
|
}
|
|
64
82
|
return JSON.parse(await response.text());
|
|
65
83
|
}
|
|
66
84
|
async verifyIndex(metadata, source) {
|
|
67
|
-
const
|
|
68
|
-
const verified = (
|
|
85
|
+
const verifiedInput = await this.verifyOriginal(metadata, this.trustedKeysFor(source.trustTier));
|
|
86
|
+
const verified = parsePluginDistributionMetadata(pluginCatalogIndexSchema, verifiedInput);
|
|
69
87
|
return {
|
|
70
88
|
...verified,
|
|
71
89
|
plugins: verified.plugins.map((entry) => this.normalizeEntry(entry, source)),
|
|
@@ -74,18 +92,22 @@ export class PluginRegistryClient {
|
|
|
74
92
|
async fetchRevocations(source) {
|
|
75
93
|
const url = new URL("revoked.json", source.url).toString();
|
|
76
94
|
const metadata = await this.fetchJson(url);
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
return verified;
|
|
95
|
+
const verifiedInput = await this.verifyOriginal(metadata, this.trustedKeysFor(source.trustTier));
|
|
96
|
+
return parsePluginDistributionMetadata(pluginRevocationIndexSchema, verifiedInput);
|
|
80
97
|
}
|
|
81
98
|
async verifyDetail(metadata, entry) {
|
|
82
|
-
const
|
|
83
|
-
const verified = (
|
|
99
|
+
const verifiedInput = await this.verifyOriginal(metadata, this.options.trustedKeys);
|
|
100
|
+
const verified = parsePluginDistributionMetadata(pluginCatalogDetailSchema, verifiedInput);
|
|
84
101
|
if (verified.id !== entry.id) {
|
|
85
102
|
throw new PluginDistributionError("metadata-invalid", `Plugin detail id ${verified.id} does not match catalog entry ${entry.id}.`, { details: { entryId: entry.id, detailId: verified.id } });
|
|
86
103
|
}
|
|
87
104
|
return verified;
|
|
88
105
|
}
|
|
106
|
+
async verifyOriginal(metadata, keys) {
|
|
107
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
|
|
108
|
+
throw new PluginDistributionError("metadata-invalid", "Expected signed registry object.");
|
|
109
|
+
return verifyInlineSignedMetadata(metadata, metadata.signatures, keys);
|
|
110
|
+
}
|
|
89
111
|
normalizeEntry(entry, source) {
|
|
90
112
|
const provenance = source.trustTier === "official" ? "official" : "community";
|
|
91
113
|
assertPluginIdAllowedForProvenance(entry.id, provenance);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DataAdapter } from "../storage/fs";
|
|
2
|
+
import type { PluginRegistrySourcePreferencesState } from "./types";
|
|
3
|
+
export declare class PluginRegistrySourcePreferenceStore {
|
|
4
|
+
private readonly adapter;
|
|
5
|
+
private readonly options;
|
|
6
|
+
constructor(adapter: DataAdapter, options?: {
|
|
7
|
+
path?: string;
|
|
8
|
+
now?: () => Date;
|
|
9
|
+
});
|
|
10
|
+
load(): Promise<PluginRegistrySourcePreferencesState>;
|
|
11
|
+
save(state: PluginRegistrySourcePreferencesState): Promise<void>;
|
|
12
|
+
private emptyState;
|
|
13
|
+
private get path();
|
|
14
|
+
private now;
|
|
15
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { dirname } from "../storage/path";
|
|
2
|
+
import { ensureFolder } from "./installed-plugin-state";
|
|
3
|
+
import { parsePluginDistributionMetadata, pluginRegistrySourcePreferencesStateSchema, } from "./schemas";
|
|
4
|
+
export class PluginRegistrySourcePreferenceStore {
|
|
5
|
+
adapter;
|
|
6
|
+
options;
|
|
7
|
+
constructor(adapter, options = {}) {
|
|
8
|
+
this.adapter = adapter;
|
|
9
|
+
this.options = options;
|
|
10
|
+
}
|
|
11
|
+
async load() {
|
|
12
|
+
try {
|
|
13
|
+
const raw = await this.adapter.read(this.path);
|
|
14
|
+
return parsePluginDistributionMetadata(pluginRegistrySourcePreferencesStateSchema, JSON.parse(raw));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return this.emptyState();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async save(state) {
|
|
21
|
+
const next = {
|
|
22
|
+
schemaVersion: 1,
|
|
23
|
+
updatedAt: this.now(),
|
|
24
|
+
sources: state.sources ?? {},
|
|
25
|
+
};
|
|
26
|
+
await ensureFolder(this.adapter, dirname(this.path));
|
|
27
|
+
await this.adapter.write(this.path, JSON.stringify(next, null, 2));
|
|
28
|
+
}
|
|
29
|
+
emptyState() {
|
|
30
|
+
return {
|
|
31
|
+
schemaVersion: 1,
|
|
32
|
+
updatedAt: this.now(),
|
|
33
|
+
sources: {},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
get path() {
|
|
37
|
+
return this.options.path ?? ".obsidian/plugin-registry-sources.json";
|
|
38
|
+
}
|
|
39
|
+
now() {
|
|
40
|
+
return (this.options.now?.() ?? new Date()).toISOString();
|
|
41
|
+
}
|
|
42
|
+
}
|