@lapis-notes/api 0.1.0 → 0.1.2

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.
Files changed (109) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +5 -0
  3. package/dist/app-database-host.d.ts +1 -0
  4. package/dist/app-database-host.js +1 -0
  5. package/dist/browser-sync-security.d.ts +45 -0
  6. package/dist/browser-sync-security.js +189 -0
  7. package/dist/build-tools/vite.d.ts +15 -0
  8. package/dist/build-tools/vite.js +36 -0
  9. package/dist/cache.svelte.d.ts +1 -1
  10. package/dist/cache.svelte.js +4 -1
  11. package/dist/components/editor/editor.css +54 -2
  12. package/dist/components/editor/extensions/lint/lapis-lint-tooltip.svelte +18 -5
  13. package/dist/components/editor/extensions/lint/lint-tooltip-actions.d.ts +0 -2
  14. package/dist/components/editor/extensions/lint/lint-tooltip-actions.js +0 -14
  15. package/dist/components/editor/language-service/index.js +23 -2
  16. package/dist/components/icon/icon-list.svelte.d.ts +1 -1
  17. package/dist/components/select/select.svelte.d.ts +1 -1
  18. package/dist/context.svelte.d.ts +11 -1
  19. package/dist/context.svelte.js +38 -1
  20. package/dist/docs-api.d.ts +2 -0
  21. package/dist/docs-api.js +1 -0
  22. package/dist/generated/plugin-host-modules.schema.generated.d.ts +443 -13
  23. package/dist/generated/plugin-host-modules.schema.generated.js +970 -306
  24. package/dist/index.d.ts +5 -0
  25. package/dist/index.js +5 -0
  26. package/dist/language-service/manager.d.ts +5 -0
  27. package/dist/language-service/manager.js +58 -14
  28. package/dist/lapis-extension.d.ts +1 -1
  29. package/dist/lapis-extension.js +12 -0
  30. package/dist/nostr-signer.d.ts +68 -0
  31. package/dist/nostr-signer.js +49 -0
  32. package/dist/plugin-asset-server.d.ts +1 -0
  33. package/dist/plugin-asset-server.js +3 -0
  34. package/dist/plugin-capability-facade.d.ts +1 -1
  35. package/dist/plugin-capability-facade.js +6 -0
  36. package/dist/plugin-distribution/catalog-media.d.ts +4 -0
  37. package/dist/plugin-distribution/catalog-media.js +40 -0
  38. package/dist/plugin-distribution/download-stats.d.ts +4 -0
  39. package/dist/plugin-distribution/download-stats.js +19 -0
  40. package/dist/plugin-distribution/index.d.ts +4 -0
  41. package/dist/plugin-distribution/index.js +4 -0
  42. package/dist/plugin-distribution/installer.d.ts +3 -0
  43. package/dist/plugin-distribution/installer.js +9 -2
  44. package/dist/plugin-distribution/manager.d.ts +71 -5
  45. package/dist/plugin-distribution/manager.js +376 -37
  46. package/dist/plugin-distribution/nostr-registry-adapter.d.ts +29 -0
  47. package/dist/plugin-distribution/nostr-registry-adapter.js +152 -0
  48. package/dist/plugin-distribution/plugin-bundle-verifier.d.ts +43 -0
  49. package/dist/plugin-distribution/plugin-bundle-verifier.js +64 -1
  50. package/dist/plugin-distribution/plugin-bundle.d.ts +14 -0
  51. package/dist/plugin-distribution/plugin-bundle.js +46 -7
  52. package/dist/plugin-distribution/plugin-markdown.d.ts +6 -0
  53. package/dist/plugin-distribution/plugin-markdown.js +94 -0
  54. package/dist/plugin-distribution/registry-client.d.ts +6 -1
  55. package/dist/plugin-distribution/registry-client.js +32 -10
  56. package/dist/plugin-distribution/registry-source-state.d.ts +15 -0
  57. package/dist/plugin-distribution/registry-source-state.js +42 -0
  58. package/dist/plugin-distribution/schemas.d.ts +2511 -2824
  59. package/dist/plugin-distribution/schemas.js +111 -42
  60. package/dist/plugin-distribution/types.d.ts +116 -5
  61. package/dist/plugin-distribution-host.d.ts +4 -0
  62. package/dist/plugin-distribution-host.js +2 -0
  63. package/dist/plugin-install-progress.js +1 -1
  64. package/dist/plugin-manager.d.ts +3 -0
  65. package/dist/plugin-manager.js +21 -13
  66. package/dist/plugin-settings.d.ts +21 -0
  67. package/dist/plugin-settings.js +41 -0
  68. package/dist/plugin.d.ts +13 -0
  69. package/dist/plugin.js +56 -0
  70. package/dist/registry-schema/catalog-v1.d.ts +4143 -0
  71. package/dist/registry-schema/catalog-v1.js +172 -0
  72. package/dist/registry-schema/catalog-v2.d.ts +5275 -0
  73. package/dist/registry-schema/catalog-v2.js +137 -0
  74. package/dist/registry-schema/index.d.ts +1 -0
  75. package/dist/registry-schema/index.js +1 -0
  76. package/dist/registry-schema/release-notes.d.ts +5 -0
  77. package/dist/registry-schema/release-notes.js +21 -0
  78. package/dist/server-host.d.ts +28 -0
  79. package/dist/server-host.js +17 -0
  80. package/dist/storage/app-database.d.ts +37 -2
  81. package/dist/storage/app-database.js +41 -1
  82. package/dist/storage/browser-coordinated-app-database.d.ts +3 -1
  83. package/dist/storage/browser-coordinated-app-database.js +8 -0
  84. package/dist/storage/browser.d.ts +4 -1
  85. package/dist/storage/browser.js +84 -0
  86. package/dist/storage/desktop-native.d.ts +4 -2
  87. package/dist/storage/desktop-native.js +90 -1
  88. package/dist/storage/fs.d.ts +17 -0
  89. package/dist/storage/index.d.ts +1 -0
  90. package/dist/storage/index.js +1 -0
  91. package/dist/storage/memory-vault-adapter.d.ts +3 -1
  92. package/dist/storage/memory-vault-adapter.js +60 -0
  93. package/dist/storage/remote-app-database.d.ts +19 -0
  94. package/dist/storage/remote-app-database.js +130 -0
  95. package/dist/storage/task-projection.d.ts +2 -1
  96. package/dist/storage/task-projection.js +7 -2
  97. package/dist/storage/turso-app-database.d.ts +3 -1
  98. package/dist/storage/turso-app-database.js +45 -0
  99. package/dist/storage/vault-session.js +11 -0
  100. package/dist/storage/vault-state.d.ts +1 -1
  101. package/dist/storage/vault.svelte.d.ts +3 -1
  102. package/dist/storage/vault.svelte.js +76 -3
  103. package/dist/sync-host-security.d.ts +285 -0
  104. package/dist/sync-host-security.js +2237 -0
  105. package/dist/sync-host.d.ts +179 -0
  106. package/dist/sync-host.js +769 -0
  107. package/dist/workspace.svelte.d.ts +1 -1
  108. package/dist/workspace.svelte.js +1 -1
  109. 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 (!entries.has(PLUGIN_BUNDLE_RELEASE_MANIFEST_PATH)) {
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 (firstEntry?.path !== PLUGIN_BUNDLE_RELEASE_MANIFEST_PATH) {
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 (firstEntry.compressionMethod !== PLUGIN_BUNDLE_STORED_METHOD) {
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 (!seenPaths.has(PLUGIN_BUNDLE_RELEASE_MANIFEST_PATH)) {
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 fetchJson(url) {
60
- const response = await this.fetchImpl(url);
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 index = parsePluginDistributionMetadata(pluginCatalogIndexSchema, metadata);
68
- const verified = (await verifyInlineSignedMetadata(index, index.signatures, this.trustedKeysFor(source.trustTier)));
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 revocations = parsePluginDistributionMetadata(pluginRevocationIndexSchema, metadata);
78
- const verified = await verifyInlineSignedMetadata(revocations, revocations.signatures, this.trustedKeysFor(source.trustTier));
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 detail = parsePluginDistributionMetadata(pluginCatalogDetailSchema, metadata);
83
- const verified = (await verifyInlineSignedMetadata(detail, detail.signatures, this.options.trustedKeys));
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
+ }