@openbrf/plugin-sdk 0.0.0-stage → 1.0.0
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 +661 -0
- package/LICENSE-EXCEPTION.md +28 -0
- package/dist/action-schema.d.ts +42 -0
- package/dist/action-schema.d.ts.map +1 -0
- package/dist/action-schema.js +64 -0
- package/dist/action-schema.js.map +1 -0
- package/dist/actions.d.ts +199 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +68 -0
- package/dist/actions.js.map +1 -0
- package/dist/api-version.d.ts +23 -0
- package/dist/api-version.d.ts.map +1 -0
- package/dist/api-version.js +27 -0
- package/dist/api-version.js.map +1 -0
- package/dist/catalog.d.ts +315 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +203 -0
- package/dist/catalog.js.map +1 -0
- package/dist/define-plugin.d.ts +14 -0
- package/dist/define-plugin.d.ts.map +1 -0
- package/dist/define-plugin.js +15 -0
- package/dist/define-plugin.js.map +1 -0
- package/dist/findings.d.ts +67 -0
- package/dist/findings.d.ts.map +1 -0
- package/dist/findings.js +57 -0
- package/dist/findings.js.map +1 -0
- package/dist/host.d.ts +208 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +41 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/integrity.d.ts +38 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +105 -0
- package/dist/integrity.js.map +1 -0
- package/dist/manifest.d.ts +392 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +324 -0
- package/dist/manifest.js.map +1 -0
- package/dist/module.d.ts +35 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +2 -0
- package/dist/module.js.map +1 -0
- package/dist/package-check.d.ts +66 -0
- package/dist/package-check.d.ts.map +1 -0
- package/dist/package-check.js +755 -0
- package/dist/package-check.js.map +1 -0
- package/dist/permissions.d.ts +65 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/permissions.js +69 -0
- package/dist/permissions.js.map +1 -0
- package/dist/settings-schema.d.ts +95 -0
- package/dist/settings-schema.d.ts.map +1 -0
- package/dist/settings-schema.js +219 -0
- package/dist/settings-schema.js.map +1 -0
- package/dist/standard-schema.d.ts +43 -0
- package/dist/standard-schema.d.ts.map +1 -0
- package/dist/standard-schema.js +2 -0
- package/dist/standard-schema.js.map +1 -0
- package/package.json +55 -3
- package/src/action-schema.ts +86 -0
- package/src/actions.ts +247 -0
- package/src/api-version.ts +28 -0
- package/src/catalog.ts +236 -0
- package/src/define-plugin.ts +18 -0
- package/src/findings.ts +71 -0
- package/src/host.ts +240 -0
- package/src/index.ts +123 -0
- package/src/integrity.ts +125 -0
- package/src/manifest.ts +385 -0
- package/src/module.ts +38 -0
- package/src/package-check.ts +894 -0
- package/src/permissions.ts +75 -0
- package/src/settings-schema.ts +272 -0
- package/src/standard-schema.ts +39 -0
- package/README.md +0 -3
package/src/host.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { PluginActions } from "./actions.ts";
|
|
2
|
+
import type { PluginPermission } from "./permissions.ts";
|
|
3
|
+
import type { PluginSettingsValues } from "./settings-schema.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The host services a plugin receives.
|
|
7
|
+
*
|
|
8
|
+
* Every service is present on the object whether or not the plugin declared
|
|
9
|
+
* the permission it needs; the ones it did not declare throw
|
|
10
|
+
* PluginPermissionError on use. Handing out nulls instead would push a
|
|
11
|
+
* null-check into every call site of a plugin that did declare the permission,
|
|
12
|
+
* for the sake of a case the plugin author has already ruled out by writing
|
|
13
|
+
* the manifest. `permissions` is on the object for the plugin that genuinely
|
|
14
|
+
* wants to degrade rather than fail.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export class PluginPermissionError extends Error {
|
|
18
|
+
constructor(
|
|
19
|
+
readonly pluginId: string,
|
|
20
|
+
readonly permission: PluginPermission,
|
|
21
|
+
) {
|
|
22
|
+
super(
|
|
23
|
+
`Plugin "${pluginId}" used a service requiring the "${permission}" ` +
|
|
24
|
+
`permission, which its manifest does not declare.`,
|
|
25
|
+
);
|
|
26
|
+
this.name = "PluginPermissionError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Writes to the host's log, tagged with the plugin's id.
|
|
32
|
+
*
|
|
33
|
+
* A plugin must not reach for console: its output would be indistinguishable
|
|
34
|
+
* from the core's, and an operator reading a log needs to know which plugin
|
|
35
|
+
* produced a line before they can decide whether to disable it.
|
|
36
|
+
*/
|
|
37
|
+
export interface PluginLogger {
|
|
38
|
+
debug(message: string): void;
|
|
39
|
+
info(message: string): void;
|
|
40
|
+
warn(message: string): void;
|
|
41
|
+
error(message: string, cause?: unknown): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The plugin's own settings, validated against its declared settingsSchema. */
|
|
45
|
+
export interface PluginSettings {
|
|
46
|
+
/**
|
|
47
|
+
* The stored values, with declared defaults applied. Reads the current
|
|
48
|
+
* values on each call rather than caching, so a change made in the admin
|
|
49
|
+
* interface reaches a long-running job without a restart.
|
|
50
|
+
*/
|
|
51
|
+
read(): Promise<PluginSettingsValues>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PluginMailMessage {
|
|
55
|
+
to: string;
|
|
56
|
+
subject: string;
|
|
57
|
+
/**
|
|
58
|
+
* The body, as text.
|
|
59
|
+
*
|
|
60
|
+
* Blank lines separate paragraphs; the host renders them in the house style
|
|
61
|
+
* and escapes them. There is no markup parameter, and that is the point: a
|
|
62
|
+
* message sent here leaves under the cooperative's own address and passes
|
|
63
|
+
* its DKIM signature, so a link, an image or a styled block a plugin put in
|
|
64
|
+
* it would be a link, an image or a styled block the cooperative appears to
|
|
65
|
+
* have written. A plugin that interpolates a resident's own text into a
|
|
66
|
+
* body - the ordinary case, and the one that actually happens - cannot turn
|
|
67
|
+
* it into markup from here.
|
|
68
|
+
*/
|
|
69
|
+
text: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Sends mail through the instance's configured SMTP server.
|
|
74
|
+
*
|
|
75
|
+
* The from address is the instance's and is not a parameter: mail from a
|
|
76
|
+
* housing cooperative's system has to be attributable to the cooperative.
|
|
77
|
+
* Requires the mail:send permission.
|
|
78
|
+
*/
|
|
79
|
+
export interface PluginMail {
|
|
80
|
+
send(message: PluginMailMessage): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface PluginSmsMessage {
|
|
84
|
+
/** The recipient's number in E.164 form. */
|
|
85
|
+
to: string;
|
|
86
|
+
/**
|
|
87
|
+
* The whole message, as text.
|
|
88
|
+
*
|
|
89
|
+
* SMS carries no markup, no subject and no alternative part, so what is
|
|
90
|
+
* written here is literally what the recipient reads. Its length is the
|
|
91
|
+
* plugin's own decision and is not bounded by the host: a text message is
|
|
92
|
+
* billed by length, and what a message may cost the association is the
|
|
93
|
+
* plugin's business to keep in hand rather than something a silent
|
|
94
|
+
* truncation can settle.
|
|
95
|
+
*/
|
|
96
|
+
text: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Sends text messages through the instance's configured SMS provider.
|
|
101
|
+
*
|
|
102
|
+
* The sender the message presents is the instance's and is not a parameter,
|
|
103
|
+
* for the reason the mail from address is not one: a text message from a
|
|
104
|
+
* housing cooperative's system has to be attributable to the cooperative.
|
|
105
|
+
*
|
|
106
|
+
* An instance with no provider configured cannot send at all, and the send
|
|
107
|
+
* fails rather than being dropped. Having no provider is the ordinary state of
|
|
108
|
+
* a housing cooperative that only ever mails its members, so a plugin whose
|
|
109
|
+
* work depends on SMS should read `permissions` and degrade, or treat the
|
|
110
|
+
* failure as the answer. Requires the sms:send permission.
|
|
111
|
+
*/
|
|
112
|
+
export interface PluginSms {
|
|
113
|
+
send(message: PluginSmsMessage): Promise<void>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Background work. Queue names are namespaced with the plugin's id by the
|
|
118
|
+
* host, so two plugins cannot collide on a name and no plugin can subscribe to
|
|
119
|
+
* a core queue. A name is 1-64 letters, digits, `_`, `.` or `-`; any other
|
|
120
|
+
* name is refused with a RangeError. Requires the jobs:schedule permission.
|
|
121
|
+
*/
|
|
122
|
+
export interface PluginJobs {
|
|
123
|
+
work<Data extends object>(
|
|
124
|
+
name: string,
|
|
125
|
+
handler: (data: Data) => Promise<void> | void,
|
|
126
|
+
): Promise<void>;
|
|
127
|
+
send<Data extends object>(name: string, data: Data): Promise<void>;
|
|
128
|
+
sendAt<Data extends object>(
|
|
129
|
+
name: string,
|
|
130
|
+
data: Data,
|
|
131
|
+
runAt: Date,
|
|
132
|
+
): Promise<void>;
|
|
133
|
+
schedule<Data extends object>(
|
|
134
|
+
name: string,
|
|
135
|
+
cron: string,
|
|
136
|
+
data: Data,
|
|
137
|
+
): Promise<void>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface PluginApartment {
|
|
141
|
+
id: string;
|
|
142
|
+
number: string;
|
|
143
|
+
floor: number | null;
|
|
144
|
+
address: { id: string; street: string; number: string };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface PluginResident {
|
|
148
|
+
personId: string;
|
|
149
|
+
name: string;
|
|
150
|
+
/** MEMBER holds the tenant-ownership; RESIDENT occupies without holding it. */
|
|
151
|
+
role: "MEMBER" | "RESIDENT";
|
|
152
|
+
apartment: PluginApartment | null;
|
|
153
|
+
movedInOn: string | null;
|
|
154
|
+
movedOutOn: string | null;
|
|
155
|
+
/** Present only with the addressBook:readContact permission. */
|
|
156
|
+
email?: string | null;
|
|
157
|
+
/** Present only with the addressBook:readContact permission. */
|
|
158
|
+
phone?: string | null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface PluginOccupancySummary {
|
|
162
|
+
apartments: number;
|
|
163
|
+
/** Distinct persons with a current residency. */
|
|
164
|
+
residents: number;
|
|
165
|
+
/** Distinct persons holding a current tenant-ownership. */
|
|
166
|
+
members: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Reading the register, scoped to what the plugin declared.
|
|
171
|
+
*
|
|
172
|
+
* Three rules hold on every method here regardless of permission, because
|
|
173
|
+
* they are the product's own (plan section 4.4) and a plugin is not a reason
|
|
174
|
+
* to relax them: a person with protected personal data never appears, a
|
|
175
|
+
* personal identity number is never returned, and nothing is writable.
|
|
176
|
+
* Requires addressBook:read; the contact fields additionally require
|
|
177
|
+
* addressBook:readContact.
|
|
178
|
+
*/
|
|
179
|
+
export interface PluginAddressBook {
|
|
180
|
+
apartments(): Promise<PluginApartment[]>;
|
|
181
|
+
/** Current residencies only. Moved-out rows are not plugin business. */
|
|
182
|
+
residents(): Promise<PluginResident[]>;
|
|
183
|
+
summary(): Promise<PluginOccupancySummary>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The host object a plugin's module factory receives.
|
|
188
|
+
*
|
|
189
|
+
* It is late-bound: the services behind it are the application's own, and the
|
|
190
|
+
* application is built after the factory has run. Every member below is
|
|
191
|
+
* therefore usable from a lifecycle hook (`onModuleInit` onwards), a guard or
|
|
192
|
+
* a request handler, and not from a provider's constructor - which is where
|
|
193
|
+
* NestJS asks for work to be done in any case. A call made too early throws
|
|
194
|
+
* rather than reading a half-built application.
|
|
195
|
+
*
|
|
196
|
+
* A plugin the board has switched off keeps its object, and every service on
|
|
197
|
+
* it refuses. Disabling has to bite immediately, and a plugin whose own timer
|
|
198
|
+
* outlived the switch must not still be reading the register through it.
|
|
199
|
+
*/
|
|
200
|
+
export interface PluginHost {
|
|
201
|
+
/** The plugin's own id, as declared in its manifest. */
|
|
202
|
+
id: string;
|
|
203
|
+
/** What the manifest declared and the board consented to. */
|
|
204
|
+
permissions: readonly PluginPermission[];
|
|
205
|
+
logger: PluginLogger;
|
|
206
|
+
settings: PluginSettings;
|
|
207
|
+
mail: PluginMail;
|
|
208
|
+
sms: PluginSms;
|
|
209
|
+
jobs: PluginJobs;
|
|
210
|
+
addressBook: PluginAddressBook;
|
|
211
|
+
/**
|
|
212
|
+
* The actions this plugin declared, and dispatch for every action it may
|
|
213
|
+
* reach.
|
|
214
|
+
*
|
|
215
|
+
* Added after this contract version shipped, so a plugin built against a
|
|
216
|
+
* newer host than the one it is installed on must feature-detect before
|
|
217
|
+
* using it - `isSupportedApiVersion` cannot see a member added to an
|
|
218
|
+
* interface, and the failure would otherwise surface as a load-failed
|
|
219
|
+
* finding with the message deliberately withheld.
|
|
220
|
+
*/
|
|
221
|
+
actions: PluginActions;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A host service was used before the application finished starting, or after
|
|
226
|
+
* the plugin stopped serving.
|
|
227
|
+
*
|
|
228
|
+
* Both cases are the plugin reaching for the host at a moment the host cannot
|
|
229
|
+
* answer for: the first is work done in a constructor that belongs in
|
|
230
|
+
* `onModuleInit`, the second is a plugin the board switched off.
|
|
231
|
+
*/
|
|
232
|
+
export class PluginHostUnavailableError extends Error {
|
|
233
|
+
constructor(
|
|
234
|
+
readonly pluginId: string,
|
|
235
|
+
readonly detail: string,
|
|
236
|
+
) {
|
|
237
|
+
super(`Plugin "${pluginId}" used a host service ${detail}`);
|
|
238
|
+
this.name = "PluginHostUnavailableError";
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @openbrf/plugin-sdk - the public plugin contract for Open BRF.
|
|
3
|
+
*
|
|
4
|
+
* A plugin depends on this package for types and for the manifest schema it
|
|
5
|
+
* validates itself against in its own CI. At runtime the host injects
|
|
6
|
+
* everything a plugin uses (see PluginHost), so nothing exported here needs to
|
|
7
|
+
* be resolvable from inside an installed plugin directory - which is what lets
|
|
8
|
+
* a plugin ship as a self-contained bundle (ADR 0003).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
ACTION_CHANNELS,
|
|
13
|
+
ACTION_EFFECTS,
|
|
14
|
+
ACTION_NAME_PATTERN,
|
|
15
|
+
ACTION_PERSONAL_DATA,
|
|
16
|
+
ACTION_SURFACES,
|
|
17
|
+
type ActionChannel,
|
|
18
|
+
type ActionClient,
|
|
19
|
+
type ActionContext,
|
|
20
|
+
type ActionDefinition,
|
|
21
|
+
type ActionEffect,
|
|
22
|
+
type ActionErrorSpec,
|
|
23
|
+
type ActionPersonalData,
|
|
24
|
+
type ActionPrincipal,
|
|
25
|
+
type ActionRequest,
|
|
26
|
+
type ActionSummary,
|
|
27
|
+
type ActionSurface,
|
|
28
|
+
type PluginActionFilter,
|
|
29
|
+
type PluginActionRegistration,
|
|
30
|
+
type PluginActions,
|
|
31
|
+
} from "./actions.ts";
|
|
32
|
+
export {
|
|
33
|
+
actionInputJsonSchema,
|
|
34
|
+
actionOutputJsonSchema,
|
|
35
|
+
isHostZodSchema,
|
|
36
|
+
} from "./action-schema.ts";
|
|
37
|
+
export {
|
|
38
|
+
isSupportedApiVersion,
|
|
39
|
+
PLUGIN_API_VERSION,
|
|
40
|
+
SUPPORTED_PLUGIN_API_VERSIONS,
|
|
41
|
+
} from "./api-version.ts";
|
|
42
|
+
export {
|
|
43
|
+
type Catalog,
|
|
44
|
+
type CatalogArtifact,
|
|
45
|
+
catalogArtifactSchema,
|
|
46
|
+
type CatalogEntry,
|
|
47
|
+
catalogEntrySchema,
|
|
48
|
+
type CatalogParseResult,
|
|
49
|
+
type CatalogPluginEntry,
|
|
50
|
+
catalogPluginEntrySchema,
|
|
51
|
+
catalogSchema,
|
|
52
|
+
type CatalogThemeEntry,
|
|
53
|
+
catalogThemeEntrySchema,
|
|
54
|
+
type LocalizedText,
|
|
55
|
+
parseCatalogIndex,
|
|
56
|
+
} from "./catalog.ts";
|
|
57
|
+
export { definePlugin } from "./define-plugin.ts";
|
|
58
|
+
export { formatSha512, IntegrityError, parseSha512 } from "./integrity.ts";
|
|
59
|
+
export {
|
|
60
|
+
PLUGIN_FINDING_REASONS,
|
|
61
|
+
type PluginFindingDetail,
|
|
62
|
+
type PluginFindingReason,
|
|
63
|
+
} from "./findings.ts";
|
|
64
|
+
export {
|
|
65
|
+
type PluginAddressBook,
|
|
66
|
+
type PluginApartment,
|
|
67
|
+
type PluginHost,
|
|
68
|
+
PluginHostUnavailableError,
|
|
69
|
+
type PluginJobs,
|
|
70
|
+
type PluginLogger,
|
|
71
|
+
type PluginMail,
|
|
72
|
+
type PluginMailMessage,
|
|
73
|
+
type PluginOccupancySummary,
|
|
74
|
+
PluginPermissionError,
|
|
75
|
+
type PluginResident,
|
|
76
|
+
type PluginSettings,
|
|
77
|
+
type PluginSms,
|
|
78
|
+
type PluginSmsMessage,
|
|
79
|
+
} from "./host.ts";
|
|
80
|
+
export type { PluginModuleFactory } from "./module.ts";
|
|
81
|
+
export {
|
|
82
|
+
assertPluginPackage,
|
|
83
|
+
composedActionName,
|
|
84
|
+
CURRENT_PLUGIN_API_VERSION,
|
|
85
|
+
type ManifestParseResult,
|
|
86
|
+
type PluginActionDeclaration,
|
|
87
|
+
pluginActionSchema,
|
|
88
|
+
pluginActionsSchema,
|
|
89
|
+
type PluginEntry,
|
|
90
|
+
pluginEntrySchema,
|
|
91
|
+
pluginIdSchema,
|
|
92
|
+
type PluginManifest,
|
|
93
|
+
pluginManifestSchema,
|
|
94
|
+
type PluginPackage,
|
|
95
|
+
pluginPackageSchema,
|
|
96
|
+
parsePluginPackage,
|
|
97
|
+
} from "./manifest.ts";
|
|
98
|
+
export {
|
|
99
|
+
HOST_SHARED_PACKAGES,
|
|
100
|
+
type PluginPackageContents,
|
|
101
|
+
pluginPackageProblems,
|
|
102
|
+
} from "./package-check.ts";
|
|
103
|
+
export type {
|
|
104
|
+
StandardSchemaIssue,
|
|
105
|
+
StandardSchemaResult,
|
|
106
|
+
StandardSchemaV1,
|
|
107
|
+
} from "./standard-schema.ts";
|
|
108
|
+
export {
|
|
109
|
+
isPluginPermission,
|
|
110
|
+
PLUGIN_PERMISSIONS,
|
|
111
|
+
PLUGIN_PERSONAL_DATA_CATEGORIES,
|
|
112
|
+
type PluginPermission,
|
|
113
|
+
type PluginPersonalDataCategory,
|
|
114
|
+
} from "./permissions.ts";
|
|
115
|
+
export {
|
|
116
|
+
defaultSettings,
|
|
117
|
+
type PluginSettingsField,
|
|
118
|
+
pluginSettingsFieldSchema,
|
|
119
|
+
type PluginSettingsSchema,
|
|
120
|
+
pluginSettingsSchema,
|
|
121
|
+
type PluginSettingsValues,
|
|
122
|
+
settingsValidator,
|
|
123
|
+
} from "./settings-schema.ts";
|
package/src/integrity.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a catalog entry spells the sha512 digest of its tarball.
|
|
3
|
+
*
|
|
4
|
+
* The instance verifies the digest before it unpacks a download, and the
|
|
5
|
+
* catalog's own check verifies it before a listing is merged. Both must accept
|
|
6
|
+
* exactly the same spellings, or the catalog passes an entry the instance then
|
|
7
|
+
* refuses. So the parsing lives here, in the contract both depend on, and
|
|
8
|
+
* nowhere else.
|
|
9
|
+
*
|
|
10
|
+
* Written without `Buffer` or `node:crypto`: this package is also bundled into
|
|
11
|
+
* apps/web, a browser, where neither exists, and hashing itself stays with the
|
|
12
|
+
* caller. Base64 is decoded by hand rather than with `atob` so that padding and
|
|
13
|
+
* stray trailing bits are read as `Buffer` reads them, whatever the runtime.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const SRI_PATTERN = /^sha512-([A-Za-z0-9+/]+)={0,2}$/;
|
|
17
|
+
const HEX_PATTERN = /^[0-9a-f]{128}$/i;
|
|
18
|
+
|
|
19
|
+
const SHA512_BYTES = 64;
|
|
20
|
+
const BASE64_ALPHABET =
|
|
21
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Why a digest was refused. `malformed-digest` is a catalog entry that is
|
|
25
|
+
* written wrongly; `digest-mismatch` is bytes that are not the ones the entry
|
|
26
|
+
* names. Installers branch on the difference.
|
|
27
|
+
*/
|
|
28
|
+
export class IntegrityError extends Error {
|
|
29
|
+
constructor(
|
|
30
|
+
message: string,
|
|
31
|
+
readonly reason: "malformed-digest" | "digest-mismatch",
|
|
32
|
+
) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "IntegrityError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Normalizes a declared digest to raw bytes.
|
|
40
|
+
*
|
|
41
|
+
* Both spellings are accepted because both are what a publisher actually has
|
|
42
|
+
* to hand: `npm pack --json` reports the subresource-integrity form
|
|
43
|
+
* (`sha512-<base64>`), while `sha512sum` prints hex. Requiring one would mean
|
|
44
|
+
* every catalog entry is transcribed by hand from the other, which is how a
|
|
45
|
+
* digest ends up wrong in a way nobody notices until an install fails.
|
|
46
|
+
*
|
|
47
|
+
* @throws IntegrityError with reason `malformed-digest`.
|
|
48
|
+
*/
|
|
49
|
+
export function parseSha512(declared: string): Uint8Array {
|
|
50
|
+
const trimmed = declared.trim();
|
|
51
|
+
|
|
52
|
+
const sri = SRI_PATTERN.exec(trimmed);
|
|
53
|
+
if (sri !== null) {
|
|
54
|
+
const bytes = fromBase64(sri[1] ?? "");
|
|
55
|
+
if (bytes.length !== SHA512_BYTES) {
|
|
56
|
+
throw new IntegrityError(
|
|
57
|
+
"A sha512 digest is 64 bytes; this one is not.",
|
|
58
|
+
"malformed-digest",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return bytes;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (HEX_PATTERN.test(trimmed)) {
|
|
65
|
+
return fromHex(trimmed);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
throw new IntegrityError(
|
|
69
|
+
'Expected a digest written as "sha512-<base64>" or 128 hex characters.',
|
|
70
|
+
"malformed-digest",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The subresource-integrity spelling, which is what a catalog entry carries. */
|
|
75
|
+
export function formatSha512(digest: Uint8Array): string {
|
|
76
|
+
return `sha512-${toBase64(digest)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fromHex(hex: string): Uint8Array {
|
|
80
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
81
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
82
|
+
bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
|
|
83
|
+
}
|
|
84
|
+
return bytes;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Standard base64 without its padding, which the caller has already split off.
|
|
89
|
+
* Stray trailing bits are dropped, as `Buffer.from(_, "base64")` drops them.
|
|
90
|
+
*/
|
|
91
|
+
function fromBase64(characters: string): Uint8Array {
|
|
92
|
+
const bytes = new Uint8Array(Math.floor((characters.length * 6) / 8));
|
|
93
|
+
let accumulator = 0;
|
|
94
|
+
let bits = 0;
|
|
95
|
+
let written = 0;
|
|
96
|
+
|
|
97
|
+
for (const character of characters) {
|
|
98
|
+
accumulator = (accumulator << 6) | BASE64_ALPHABET.indexOf(character);
|
|
99
|
+
bits += 6;
|
|
100
|
+
if (bits >= 8) {
|
|
101
|
+
bits -= 8;
|
|
102
|
+
bytes[written] = (accumulator >> bits) & 0xff;
|
|
103
|
+
written += 1;
|
|
104
|
+
accumulator &= (1 << bits) - 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return bytes;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function toBase64(bytes: Uint8Array): string {
|
|
111
|
+
let encoded = "";
|
|
112
|
+
for (let index = 0; index < bytes.length; index += 3) {
|
|
113
|
+
const first = bytes[index] ?? 0;
|
|
114
|
+
const second = bytes[index + 1] ?? 0;
|
|
115
|
+
const third = bytes[index + 2] ?? 0;
|
|
116
|
+
const group = (first << 16) | (second << 8) | third;
|
|
117
|
+
const remaining = bytes.length - index;
|
|
118
|
+
|
|
119
|
+
encoded += BASE64_ALPHABET.charAt((group >> 18) & 63);
|
|
120
|
+
encoded += BASE64_ALPHABET.charAt((group >> 12) & 63);
|
|
121
|
+
encoded += remaining > 1 ? BASE64_ALPHABET.charAt((group >> 6) & 63) : "=";
|
|
122
|
+
encoded += remaining > 2 ? BASE64_ALPHABET.charAt(group & 63) : "=";
|
|
123
|
+
}
|
|
124
|
+
return encoded;
|
|
125
|
+
}
|