@capybara-hq/kernel 0.1.0-rc.14 → 0.1.0-rc.16
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/dist/admin/admin.schemas.d.ts +5 -13
- package/dist/catalog/admin-catalog.controller.d.ts +27 -4
- package/dist/catalog/admin-catalog.controller.js +53 -5
- package/dist/catalog/appearance.schemas.d.ts +10 -0
- package/dist/catalog/appearance.schemas.js +20 -0
- package/dist/catalog/appearance.service.d.ts +105 -0
- package/dist/catalog/appearance.service.js +306 -0
- package/dist/catalog/catalog-import.module.js +3 -0
- package/dist/catalog/catalog-items.d.ts +11 -0
- package/dist/catalog/catalog-items.js +21 -0
- package/dist/catalog/catalog-scope.service.d.ts +7 -0
- package/dist/catalog/catalog-scope.service.js +55 -15
- package/dist/catalog/catalog.service.js +10 -2
- package/dist/catalog/cli.js +3 -0
- package/dist/catalog/marketplaces.service.d.ts +1 -1
- package/dist/catalog/org-extension.controller.d.ts +27 -7
- package/dist/catalog/org-extension.controller.js +48 -6
- package/dist/catalog/plugin-import.d.ts +28 -4
- package/dist/catalog/plugin-import.js +95 -19
- package/dist/catalog/plugin-package.d.ts +4 -2
- package/dist/catalog/plugin-package.js +29 -3
- package/dist/channels/channels.schemas.d.ts +4 -0
- package/dist/channels/channels.schemas.js +2 -0
- package/dist/channels/connections.controller.d.ts +5 -3
- package/dist/channels/connections.controller.js +6 -4
- package/dist/channels/connections.service.d.ts +26 -0
- package/dist/channels/connections.service.js +79 -6
- package/dist/client/client-access.service.d.ts +101 -0
- package/dist/client/client-access.service.js +294 -0
- package/dist/client/client-builds.service.d.ts +109 -0
- package/dist/client/client-builds.service.js +229 -0
- package/dist/client/client-delivery.controller.d.ts +25 -0
- package/dist/client/client-delivery.controller.js +66 -0
- package/dist/client/client-files.d.ts +48 -0
- package/dist/client/client-files.js +136 -0
- package/dist/client/client-reports.cron.d.ts +13 -0
- package/dist/client/client-reports.cron.js +42 -0
- package/dist/client/client-reports.service.d.ts +30 -0
- package/dist/client/client-reports.service.js +102 -0
- package/dist/client/client.controller.d.ts +45 -0
- package/dist/client/client.controller.js +121 -0
- package/dist/client/client.module.d.ts +7 -0
- package/dist/client/client.module.js +26 -0
- package/dist/client/client.schemas.d.ts +55 -0
- package/dist/client/client.schemas.js +27 -0
- package/dist/ctx/create-ctx.d.ts +4 -3
- package/dist/ctx/create-ctx.js +4 -3
- package/dist/db/kernel-schema.d.ts +47 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +29 -0
- package/dist/extensions/admin-ops.d.ts +4 -1
- package/dist/extensions/admin-ops.js +22 -1
- package/dist/extensions/admin-view.d.ts +8 -0
- package/dist/extensions/admin-view.js +78 -0
- package/dist/extensions/blobs.d.ts +14 -1
- package/dist/extensions/blobs.js +48 -5
- package/dist/extensions/guide-places.d.ts +3 -1
- package/dist/extensions/guide-places.js +1 -1
- package/dist/extensions/host.d.ts +6 -1
- package/dist/extensions/host.js +20 -9
- package/dist/extensions/package.d.ts +4 -3
- package/dist/extensions/package.js +5 -1
- package/dist/extensions/permission-set.d.ts +19 -3
- package/dist/extensions/permission-set.js +19 -2
- package/dist/extensions/permission-view.d.ts +38 -7
- package/dist/extensions/permission-view.js +23 -7
- package/dist/extensions/sources.d.ts +6 -0
- package/dist/extensions/sources.js +2 -0
- package/dist/extensions/state.module.js +9 -1
- package/dist/http/api.module.js +10 -0
- package/dist/identity/settings.d.ts +41 -26
- package/dist/identity/settings.js +87 -4
- package/dist/infra/throttler/named-throttlers.d.ts +4 -0
- package/dist/infra/throttler/named-throttlers.js +14 -0
- package/dist/kernel.module.js +3 -0
- package/dist/orgs/settings.service.d.ts +2 -26
- package/dist/platform/exclusions.js +27 -1
- package/dist/platform/ops/admin.ops.js +17 -2
- package/dist/platform/ops/catalog-admin.ops.d.ts +19 -1
- package/dist/platform/ops/catalog-admin.ops.js +85 -2
- package/dist/platform/ops/org-plugins.ops.d.ts +7 -1
- package/dist/platform/ops/org-plugins.ops.js +63 -4
- package/dist/platform/ops/org-settings.ops.js +2 -2
- package/dist/platform/ops/orgs.ops.js +1 -0
- package/dist/platform/ops/ui.ops.js +1 -1
- package/dist/platform/platform-ops.module.js +2 -0
- package/dist/platform/route-schemas.js +3 -0
- package/dist/roles/worker.module.js +4 -0
- package/dist/themes/render-css.d.ts +2 -1
- package/dist/themes/render-css.js +5 -2
- package/dist/themes/theme-delivery.service.d.ts +4 -2
- package/dist/themes/theme-delivery.service.js +8 -3
- package/dist/themes/theme-state.d.ts +28 -7
- package/dist/themes/theme-state.js +24 -6
- package/dist/themes/themes-public.controller.d.ts +7 -2
- package/dist/themes/themes-public.controller.js +11 -5
- package/dist/themes/themes.service.js +2 -2
- package/dist/tools/pipeline.d.ts +3 -2
- package/dist/ui/arrangement.d.ts +41 -50
- package/dist/ui/arrangement.js +88 -15
- package/dist/ui/registry.d.ts +13 -3
- package/dist/ui/registry.js +15 -4
- package/dist/ui/ui.schemas.d.ts +4 -0
- package/dist/ui/views.d.ts +36 -9
- package/dist/ui/views.js +58 -28
- package/package.json +3 -3
- package/src/migrations/0107_extension_client_builds.sql +50 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
8
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
9
|
+
};
|
|
10
|
+
import { Inject, Injectable } from "@nestjs/common";
|
|
11
|
+
import { KernelDb } from "../infra/database/kernel-db.js";
|
|
12
|
+
import { TransactionHost } from "../infra/database/transaction-host.js";
|
|
13
|
+
import { Transactional } from "../infra/database/transactional.decorator.js";
|
|
14
|
+
import { LockService } from "../infra/lock/lock.service.js";
|
|
15
|
+
import { StorageService } from "../storage/storage.service.js";
|
|
16
|
+
import { checkClientFiles, clientBuildPrefix, clientContentType, clientRelPath, } from "./client-files.js";
|
|
17
|
+
/** The summary of a stored build row. */
|
|
18
|
+
export function summarizeClientBuild(r) {
|
|
19
|
+
const files = [...(r.entry !== null ? [r.entry] : []), ...r.styles, ...r.assets].map((path) => ({
|
|
20
|
+
path,
|
|
21
|
+
bytes: r.sizes[path] ?? 0,
|
|
22
|
+
}));
|
|
23
|
+
return {
|
|
24
|
+
files,
|
|
25
|
+
totalBytes: files.reduce((n, f) => n + f.bytes, 0),
|
|
26
|
+
apiVersion: r.apiVersion,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The client builds of extension packages (client extensions spec §9.1): when a package with an
|
|
31
|
+
* `extension.client` section is stored, its client files are checked (`client-files.ts`) and
|
|
32
|
+
* written to object storage under `deployment/platform/client/<plugin>/<sha256>/<path inside
|
|
33
|
+
* dist/client/>`, and one `extension_client_builds` row records the keys, sizes and integrity.
|
|
34
|
+
* Content-addressed by the package sha256, so storing the same package again is harmless.
|
|
35
|
+
*
|
|
36
|
+
* The objects are written before the row's transaction (object storage is never called inside
|
|
37
|
+
* one). A transaction that rolls back after the objects were written leaves them unreferenced
|
|
38
|
+
* under the prefix: nothing sweeps them, and the next store of the same package rewrites them
|
|
39
|
+
* (content-addressed, so they are never wrong, only orphaned). The row references
|
|
40
|
+
* `extension_packages` (ON DELETE CASCADE); whoever deletes the package row calls
|
|
41
|
+
* {@link deleteFiles} in `afterCommit`.
|
|
42
|
+
*
|
|
43
|
+
* Storing a package's build and deleting its files run under one lock per package sha256
|
|
44
|
+
* ({@link locked}, `LockService`, never inside a transaction), and the delete first re-checks that
|
|
45
|
+
* no package row exists: a concurrent store of the same bytes either sees the row gone and writes
|
|
46
|
+
* everything again, or has recorded the row and the delete leaves its files alone, so a build row
|
|
47
|
+
* never stays without its files.
|
|
48
|
+
*/
|
|
49
|
+
let ClientBuilds = class ClientBuilds {
|
|
50
|
+
db;
|
|
51
|
+
txHost;
|
|
52
|
+
locks;
|
|
53
|
+
/** Where client files go: the kernel's object storage, or the CLI's own ({@link useCliStorage}). */
|
|
54
|
+
storage;
|
|
55
|
+
constructor(db, txHost, storage, locks) {
|
|
56
|
+
this.db = db;
|
|
57
|
+
this.txHost = txHost;
|
|
58
|
+
this.locks = locks;
|
|
59
|
+
this.storage = storage;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* CLI only (like `PluginImport.useCliStorage`): client files go to the object store the CLI set
|
|
63
|
+
* up itself (`loadCliObjectStore`); without one, a package with client code is refused with its
|
|
64
|
+
* `storage.unavailable`. Called once, right after the command's context is created.
|
|
65
|
+
*/
|
|
66
|
+
useCliStorage(storage) {
|
|
67
|
+
this.storage = storage;
|
|
68
|
+
}
|
|
69
|
+
/** `storage.unavailable` (503) when client files cannot be stored here. */
|
|
70
|
+
assertAvailable() {
|
|
71
|
+
this.storage.assertAvailable();
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Runs `fn` under the lock of package `sha256`'s client build (see the class header): the store
|
|
75
|
+
* of a package with client code and the delete of its files. Never inside a transaction.
|
|
76
|
+
*/
|
|
77
|
+
async locked(sha256, fn) {
|
|
78
|
+
return this.locks.run(`client-build:${sha256}`, { ttlMs: 60_000, waitMs: 60_000 }, fn);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Checks the client files of package `sha256` (`files` keyed by their path in the package, see
|
|
82
|
+
* `checkClientFiles`) and writes them to object storage; returns the row to insert. No
|
|
83
|
+
* transaction may be open. `storage.unavailable` without an object store; `client.invalid` /
|
|
84
|
+
* `client.integrity_mismatch` for files that do not match the section.
|
|
85
|
+
*/
|
|
86
|
+
async writeFiles(sha256, plugin, files, section) {
|
|
87
|
+
this.storage.assertAvailable();
|
|
88
|
+
const checked = checkClientFiles(section, files);
|
|
89
|
+
const objectPrefix = clientBuildPrefix(plugin, sha256);
|
|
90
|
+
const store = this.storage.deployment();
|
|
91
|
+
for (const f of checked.files) {
|
|
92
|
+
await store.put(objectPrefix + clientRelPath(f.key), f.data, {
|
|
93
|
+
contentType: clientContentType(f.key),
|
|
94
|
+
size: f.size,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
packageSha256: sha256,
|
|
99
|
+
plugin,
|
|
100
|
+
entry: section.entry ?? null,
|
|
101
|
+
styles: section.styles ?? [],
|
|
102
|
+
assets: section.assets ?? [],
|
|
103
|
+
integrity: Object.fromEntries(checked.files.map((f) => [f.key, f.integrity])),
|
|
104
|
+
sizes: Object.fromEntries(checked.files.map((f) => [f.key, f.size])),
|
|
105
|
+
apiVersion: section.apiVersion,
|
|
106
|
+
objectPrefix,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Inserts the build row (joins the caller's transaction, which also inserts the package row);
|
|
111
|
+
* does nothing when the row exists.
|
|
112
|
+
*/
|
|
113
|
+
async insert(row) {
|
|
114
|
+
await this.db
|
|
115
|
+
.insertInto("extension_client_builds")
|
|
116
|
+
.values({
|
|
117
|
+
package_sha256: row.packageSha256,
|
|
118
|
+
plugin: row.plugin,
|
|
119
|
+
entry: row.entry,
|
|
120
|
+
styles: row.styles,
|
|
121
|
+
assets: row.assets,
|
|
122
|
+
integrity: JSON.stringify(row.integrity),
|
|
123
|
+
sizes: JSON.stringify(row.sizes),
|
|
124
|
+
api_version: row.apiVersion,
|
|
125
|
+
object_prefix: row.objectPrefix,
|
|
126
|
+
})
|
|
127
|
+
.onConflict((oc) => oc.column("package_sha256").doNothing())
|
|
128
|
+
.execute();
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* {@link writeFiles} then {@link insert}, for a package whose `extension_packages` row exists
|
|
132
|
+
* (storing it again restores missing objects and a missing row). Idempotent by sha256.
|
|
133
|
+
*/
|
|
134
|
+
async extract(sha256, plugin, files, section) {
|
|
135
|
+
await this.insert(await this.writeFiles(sha256, plugin, files, section));
|
|
136
|
+
}
|
|
137
|
+
/** The client build of package `sha256`, or null when it has none. */
|
|
138
|
+
async byPackage(sha256) {
|
|
139
|
+
const r = await this.db
|
|
140
|
+
.selectFrom("extension_client_builds")
|
|
141
|
+
.selectAll()
|
|
142
|
+
.where("package_sha256", "=", sha256)
|
|
143
|
+
.executeTakeFirst();
|
|
144
|
+
if (!r)
|
|
145
|
+
return null;
|
|
146
|
+
return {
|
|
147
|
+
packageSha256: r.package_sha256,
|
|
148
|
+
plugin: r.plugin,
|
|
149
|
+
entry: r.entry,
|
|
150
|
+
styles: r.styles,
|
|
151
|
+
assets: r.assets,
|
|
152
|
+
integrity: r.integrity,
|
|
153
|
+
sizes: r.sizes,
|
|
154
|
+
apiVersion: r.api_version,
|
|
155
|
+
objectPrefix: r.object_prefix,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** The summaries of the client builds of packages `shas` (a package without one is left out). */
|
|
159
|
+
async summaries(shas) {
|
|
160
|
+
const out = new Map();
|
|
161
|
+
if (shas.length === 0)
|
|
162
|
+
return out;
|
|
163
|
+
const rows = await this.db
|
|
164
|
+
.selectFrom("extension_client_builds")
|
|
165
|
+
.select(["package_sha256", "entry", "styles", "assets", "sizes", "api_version"])
|
|
166
|
+
.where("package_sha256", "in", [...new Set(shas)])
|
|
167
|
+
.execute();
|
|
168
|
+
for (const r of rows) {
|
|
169
|
+
out.set(r.package_sha256, summarizeClientBuild({
|
|
170
|
+
entry: r.entry,
|
|
171
|
+
styles: r.styles,
|
|
172
|
+
assets: r.assets,
|
|
173
|
+
sizes: r.sizes,
|
|
174
|
+
apiVersion: r.api_version,
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The client build summaries of the packages the install records of `plugins` name (the running
|
|
181
|
+
* version's), by plugin; a plugin without an install record or a client build is left out.
|
|
182
|
+
*/
|
|
183
|
+
async installedSummaries(plugins) {
|
|
184
|
+
const out = new Map();
|
|
185
|
+
if (plugins.length === 0)
|
|
186
|
+
return out;
|
|
187
|
+
const installs = await this.db
|
|
188
|
+
.selectFrom("extension_installs")
|
|
189
|
+
.select(["name", "sha256"])
|
|
190
|
+
.where("name", "in", [...new Set(plugins)])
|
|
191
|
+
.execute();
|
|
192
|
+
const builds = await this.summaries(installs.map((i) => i.sha256));
|
|
193
|
+
for (const i of installs) {
|
|
194
|
+
const b = builds.get(i.sha256);
|
|
195
|
+
if (b !== undefined)
|
|
196
|
+
out.set(i.name, b);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Deletes the objects of package `sha256`'s build (call in `afterCommit`, once its package row is
|
|
202
|
+
* gone): under {@link locked}, and only while no package row exists (a concurrent store of the
|
|
203
|
+
* same bytes keeps its files). Returns whether it deleted.
|
|
204
|
+
*/
|
|
205
|
+
async deleteFiles(sha256, objectPrefix) {
|
|
206
|
+
return this.locked(sha256, async () => {
|
|
207
|
+
const again = await this.db
|
|
208
|
+
.selectFrom("extension_packages")
|
|
209
|
+
.select("sha256")
|
|
210
|
+
.where("sha256", "=", sha256)
|
|
211
|
+
.executeTakeFirst();
|
|
212
|
+
if (again !== undefined)
|
|
213
|
+
return false;
|
|
214
|
+
await this.storage.deployment().deletePrefix(objectPrefix);
|
|
215
|
+
return true;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
__decorate([
|
|
220
|
+
Transactional()
|
|
221
|
+
], ClientBuilds.prototype, "insert", null);
|
|
222
|
+
ClientBuilds = __decorate([
|
|
223
|
+
Injectable(),
|
|
224
|
+
__param(0, Inject(KernelDb)),
|
|
225
|
+
__param(1, Inject(TransactionHost)),
|
|
226
|
+
__param(2, Inject(StorageService)),
|
|
227
|
+
__param(3, Inject(LockService))
|
|
228
|
+
], ClientBuilds);
|
|
229
|
+
export { ClientBuilds };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
2
|
+
import { type Principal } from "../identity/principal.js";
|
|
3
|
+
import { ClientAccess } from "./client-access.service.js";
|
|
4
|
+
/**
|
|
5
|
+
* Client file delivery (client extensions spec §9.3): `GET /api/v1/client/<plugin>/<build>/<path>`
|
|
6
|
+
* serves the entry, a stylesheet or an asset of a client build to a signed-in person allowed to load
|
|
7
|
+
* it ({@link ClientAccess.file}: one PG query per request, nothing cached); anything else is 404
|
|
8
|
+
* `client.not_found`. The web's document CSP allows scripts from this prefix only, so the route
|
|
9
|
+
* never redirects (no trailing-slash or case redirect either): a redirect would leave the path the
|
|
10
|
+
* policy checked.
|
|
11
|
+
*
|
|
12
|
+
* The file is named by the raw request path, never by the decoded wildcard parameter: the path is
|
|
13
|
+
* matched segment by segment against the manifest key's charset, so `..`, encoded separators and
|
|
14
|
+
* backslashes name nothing.
|
|
15
|
+
*
|
|
16
|
+
* Headers: `Cache-Control: private, max-age=31536000, immutable`, `X-Content-Type-Options: nosniff`,
|
|
17
|
+
* `Cross-Origin-Resource-Policy: same-origin`, the content type of the file's kind
|
|
18
|
+
* (`text/javascript`, `text/css; charset=utf-8`, the asset type), and for an SVG asset
|
|
19
|
+
* `Content-Security-Policy: default-src 'none'; sandbox`.
|
|
20
|
+
*/
|
|
21
|
+
export declare class ClientDeliveryController {
|
|
22
|
+
private readonly access;
|
|
23
|
+
constructor(access: ClientAccess);
|
|
24
|
+
file(principal: Principal, req: FastifyRequest, reply: FastifyReply): Promise<void>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
8
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
9
|
+
};
|
|
10
|
+
import { Controller, Get, Inject, Req, Res } from "@nestjs/common";
|
|
11
|
+
import { CurrentPrincipal, requireUser } from "../identity/principal.js";
|
|
12
|
+
import { ClientAccess } from "./client-access.service.js";
|
|
13
|
+
/** The route prefix, as the raw request path starts with it. */
|
|
14
|
+
const PREFIX = "/api/v1/client/";
|
|
15
|
+
/** A build's files never change (the package sha256 is in the URL); only its viewers may cache them. */
|
|
16
|
+
const IMMUTABLE_PRIVATE = "private, max-age=31536000, immutable";
|
|
17
|
+
/**
|
|
18
|
+
* Client file delivery (client extensions spec §9.3): `GET /api/v1/client/<plugin>/<build>/<path>`
|
|
19
|
+
* serves the entry, a stylesheet or an asset of a client build to a signed-in person allowed to load
|
|
20
|
+
* it ({@link ClientAccess.file}: one PG query per request, nothing cached); anything else is 404
|
|
21
|
+
* `client.not_found`. The web's document CSP allows scripts from this prefix only, so the route
|
|
22
|
+
* never redirects (no trailing-slash or case redirect either): a redirect would leave the path the
|
|
23
|
+
* policy checked.
|
|
24
|
+
*
|
|
25
|
+
* The file is named by the raw request path, never by the decoded wildcard parameter: the path is
|
|
26
|
+
* matched segment by segment against the manifest key's charset, so `..`, encoded separators and
|
|
27
|
+
* backslashes name nothing.
|
|
28
|
+
*
|
|
29
|
+
* Headers: `Cache-Control: private, max-age=31536000, immutable`, `X-Content-Type-Options: nosniff`,
|
|
30
|
+
* `Cross-Origin-Resource-Policy: same-origin`, the content type of the file's kind
|
|
31
|
+
* (`text/javascript`, `text/css; charset=utf-8`, the asset type), and for an SVG asset
|
|
32
|
+
* `Content-Security-Policy: default-src 'none'; sandbox`.
|
|
33
|
+
*/
|
|
34
|
+
let ClientDeliveryController = class ClientDeliveryController {
|
|
35
|
+
access;
|
|
36
|
+
constructor(access) {
|
|
37
|
+
this.access = access;
|
|
38
|
+
}
|
|
39
|
+
async file(principal, req, reply) {
|
|
40
|
+
const p = requireUser(principal);
|
|
41
|
+
const raw = req.raw.url ?? "";
|
|
42
|
+
const q = raw.indexOf("?");
|
|
43
|
+
const path = q < 0 ? raw : raw.slice(0, q);
|
|
44
|
+
const f = await this.access.file(p.account.id, p.account.isSuperAdmin, path.startsWith(PREFIX) ? path.slice(PREFIX.length) : "");
|
|
45
|
+
reply
|
|
46
|
+
.header("content-type", f.contentType)
|
|
47
|
+
.header("cache-control", IMMUTABLE_PRIVATE)
|
|
48
|
+
.header("x-content-type-options", "nosniff")
|
|
49
|
+
.header("cross-origin-resource-policy", "same-origin");
|
|
50
|
+
if (f.contentType === "image/svg+xml") {
|
|
51
|
+
reply.header("content-security-policy", "default-src 'none'; sandbox");
|
|
52
|
+
}
|
|
53
|
+
await reply.send(Buffer.from(f.data.buffer, f.data.byteOffset, f.data.byteLength));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
__decorate([
|
|
57
|
+
Get(":plugin/:build/*"),
|
|
58
|
+
__param(0, CurrentPrincipal()),
|
|
59
|
+
__param(1, Req()),
|
|
60
|
+
__param(2, Res())
|
|
61
|
+
], ClientDeliveryController.prototype, "file", null);
|
|
62
|
+
ClientDeliveryController = __decorate([
|
|
63
|
+
Controller("api/v1/client"),
|
|
64
|
+
__param(0, Inject(ClientAccess))
|
|
65
|
+
], ClientDeliveryController);
|
|
66
|
+
export { ClientDeliveryController };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type PluginClientSection } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
/**
|
|
3
|
+
* The client files of a plugin package (client extensions spec §4.1, §9.1), checked against its
|
|
4
|
+
* `extension.client` section: the import review and the store both run {@link checkClientFiles} on
|
|
5
|
+
* the bytes they hold, so nothing reaches object storage, and no integrity reaches a browser, that
|
|
6
|
+
* was not recomputed from the bytes.
|
|
7
|
+
*
|
|
8
|
+
* Files are known only by their manifest key (`./dist/client/<path>`): the object key and, later,
|
|
9
|
+
* the delivery lookup use the exact key, never a decoded or joined path. Every segment of a key is
|
|
10
|
+
* made of `[A-Za-z0-9._-]` only (no `%`, space, `?`, `#`, backslash or non-ASCII character, which a
|
|
11
|
+
* browser would encode or split), is not `.` or `..`, and the file name has a stem: refused here
|
|
12
|
+
* even when the SDK validator lets it through.
|
|
13
|
+
*/
|
|
14
|
+
/** The directory of client files inside the package (relative to the package root). */
|
|
15
|
+
export declare const CLIENT_DIR = "dist/client/";
|
|
16
|
+
/** The object prefix of a build, relative to `deployment/`: `platform/client/<plugin>/<sha256>/`. */
|
|
17
|
+
export declare const clientBuildPrefix: (plugin: string, sha256: string) => string;
|
|
18
|
+
/** The path of a manifest key inside `dist/client/` (`./dist/client/styles/a.css` -> `styles/a.css`). */
|
|
19
|
+
export declare const clientRelPath: (key: string) => string;
|
|
20
|
+
/** The content type a client file is stored and served with (by its manifest key). */
|
|
21
|
+
export declare function clientContentType(key: string): string;
|
|
22
|
+
/** One checked client file. */
|
|
23
|
+
export interface CheckedClientFile {
|
|
24
|
+
/** The manifest key (`./dist/client/...`). */
|
|
25
|
+
key: string;
|
|
26
|
+
data: Uint8Array;
|
|
27
|
+
size: number;
|
|
28
|
+
/** `sha384-<base64>`, equal to the section's value. */
|
|
29
|
+
integrity: string;
|
|
30
|
+
}
|
|
31
|
+
/** The checked client files of a section, in section order (entry, styles, assets). */
|
|
32
|
+
export interface CheckedClient {
|
|
33
|
+
files: CheckedClientFile[];
|
|
34
|
+
total: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Checks the client files of a package against its section and returns them. `files` holds the
|
|
38
|
+
* package's files keyed by their path relative to the package root (`dist/client/client.mjs`);
|
|
39
|
+
* files outside `dist/client/` are ignored. Refuses the whole package with:
|
|
40
|
+
* - `client.invalid` (details `[{ file, reason }]`): the section fails `validateClientSection`
|
|
41
|
+
* (file `plugin.json`), a key cannot be looked up exactly, a listed file is missing, a file
|
|
42
|
+
* under `dist/client/` is not listed, a size limit is exceeded, or a stylesheet fails the CSS
|
|
43
|
+
* check (`clientCssProblem` from `@capybara-hq/plugin-sdk`);
|
|
44
|
+
* - `client.integrity_mismatch` (details `[{ file }]`): a file's sha384 is not the section's;
|
|
45
|
+
* - `client.api_unsupported` (details `[{ apiVersion, supported }]`): the section's `apiVersion`
|
|
46
|
+
* is newer than the kernel's `CLIENT_API_VERSION` (the host could not run it).
|
|
47
|
+
*/
|
|
48
|
+
export declare function checkClientFiles(section: PluginClientSection, files: ReadonlyMap<string, Uint8Array>): CheckedClient;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { CLIENT_API_VERSION, CLIENT_ASSET_TYPES, CLIENT_ENTRY_PATH, CLIENT_LIMITS, clientCssProblem, validateClientSection, } from "@capybara-hq/plugin-sdk";
|
|
3
|
+
import { ApiError } from "../errors.js";
|
|
4
|
+
/**
|
|
5
|
+
* The client files of a plugin package (client extensions spec §4.1, §9.1), checked against its
|
|
6
|
+
* `extension.client` section: the import review and the store both run {@link checkClientFiles} on
|
|
7
|
+
* the bytes they hold, so nothing reaches object storage, and no integrity reaches a browser, that
|
|
8
|
+
* was not recomputed from the bytes.
|
|
9
|
+
*
|
|
10
|
+
* Files are known only by their manifest key (`./dist/client/<path>`): the object key and, later,
|
|
11
|
+
* the delivery lookup use the exact key, never a decoded or joined path. Every segment of a key is
|
|
12
|
+
* made of `[A-Za-z0-9._-]` only (no `%`, space, `?`, `#`, backslash or non-ASCII character, which a
|
|
13
|
+
* browser would encode or split), is not `.` or `..`, and the file name has a stem: refused here
|
|
14
|
+
* even when the SDK validator lets it through.
|
|
15
|
+
*/
|
|
16
|
+
/** The directory of client files inside the package (relative to the package root). */
|
|
17
|
+
export const CLIENT_DIR = "dist/client/";
|
|
18
|
+
const KEY_PREFIX = `./${CLIENT_DIR}`;
|
|
19
|
+
/** The object prefix of a build, relative to `deployment/`: `platform/client/<plugin>/<sha256>/`. */
|
|
20
|
+
export const clientBuildPrefix = (plugin, sha256) => `platform/client/${plugin}/${sha256}/`;
|
|
21
|
+
/** The path of a manifest key inside `dist/client/` (`./dist/client/styles/a.css` -> `styles/a.css`). */
|
|
22
|
+
export const clientRelPath = (key) => key.slice(KEY_PREFIX.length);
|
|
23
|
+
/** The content type a client file is stored and served with (by its manifest key). */
|
|
24
|
+
export function clientContentType(key) {
|
|
25
|
+
if (key === CLIENT_ENTRY_PATH)
|
|
26
|
+
return "text/javascript";
|
|
27
|
+
if (key.endsWith(".css"))
|
|
28
|
+
return "text/css";
|
|
29
|
+
const dot = key.lastIndexOf(".");
|
|
30
|
+
return CLIENT_ASSET_TYPES[key.slice(dot).toLowerCase()] ?? "application/octet-stream";
|
|
31
|
+
}
|
|
32
|
+
const invalid = (file, reason) => new ApiError("client.invalid", `The plugin's client file ${file} is invalid: ${reason}`, [
|
|
33
|
+
{ file, reason },
|
|
34
|
+
]);
|
|
35
|
+
/** One segment of a client file key: plain characters only (no decoding is ever needed). */
|
|
36
|
+
const SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
|
|
37
|
+
/** Why a manifest key cannot be stored and looked up by its exact spelling, or undefined. */
|
|
38
|
+
function keyProblem(key) {
|
|
39
|
+
if (!key.startsWith(KEY_PREFIX))
|
|
40
|
+
return `it must be under ${KEY_PREFIX}`;
|
|
41
|
+
const segs = clientRelPath(key).split("/");
|
|
42
|
+
if (segs.some((s) => s === "" || s === "." || s === "..")) {
|
|
43
|
+
return "the path has an empty, . or .. segment";
|
|
44
|
+
}
|
|
45
|
+
if (!segs.every((s) => SEGMENT_RE.test(s))) {
|
|
46
|
+
return "the path may use only letters, digits, . _ - and / (no %, spaces, ? or #)";
|
|
47
|
+
}
|
|
48
|
+
const name = segs[segs.length - 1];
|
|
49
|
+
if (name.lastIndexOf(".") <= 0)
|
|
50
|
+
return "the file name has an empty stem or no extension";
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const sha384 = (data) => `sha384-${createHash("sha384").update(data).digest("base64")}`;
|
|
54
|
+
const utf8 = (data) => {
|
|
55
|
+
try {
|
|
56
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Checks the client files of a package against its section and returns them. `files` holds the
|
|
64
|
+
* package's files keyed by their path relative to the package root (`dist/client/client.mjs`);
|
|
65
|
+
* files outside `dist/client/` are ignored. Refuses the whole package with:
|
|
66
|
+
* - `client.invalid` (details `[{ file, reason }]`): the section fails `validateClientSection`
|
|
67
|
+
* (file `plugin.json`), a key cannot be looked up exactly, a listed file is missing, a file
|
|
68
|
+
* under `dist/client/` is not listed, a size limit is exceeded, or a stylesheet fails the CSS
|
|
69
|
+
* check (`clientCssProblem` from `@capybara-hq/plugin-sdk`);
|
|
70
|
+
* - `client.integrity_mismatch` (details `[{ file }]`): a file's sha384 is not the section's;
|
|
71
|
+
* - `client.api_unsupported` (details `[{ apiVersion, supported }]`): the section's `apiVersion`
|
|
72
|
+
* is newer than the kernel's `CLIENT_API_VERSION` (the host could not run it).
|
|
73
|
+
*/
|
|
74
|
+
export function checkClientFiles(section, files) {
|
|
75
|
+
const sectionIssues = validateClientSection(section);
|
|
76
|
+
if (sectionIssues.length > 0) {
|
|
77
|
+
throw invalid("plugin.json", sectionIssues[0]);
|
|
78
|
+
}
|
|
79
|
+
if (section.apiVersion > CLIENT_API_VERSION) {
|
|
80
|
+
throw new ApiError("client.api_unsupported", `The plugin's client code was built for client API ${section.apiVersion}; this deployment ` +
|
|
81
|
+
`supports ${CLIENT_API_VERSION}. Upgrade Capybara, or build the plugin against ` +
|
|
82
|
+
`@capybara-hq/plugin-client for client API ${CLIENT_API_VERSION}.`, [{ apiVersion: section.apiVersion, supported: CLIENT_API_VERSION }]);
|
|
83
|
+
}
|
|
84
|
+
const styles = section.styles ?? [];
|
|
85
|
+
const keys = [
|
|
86
|
+
...(section.entry !== undefined ? [section.entry] : []),
|
|
87
|
+
...styles,
|
|
88
|
+
...(section.assets ?? []),
|
|
89
|
+
];
|
|
90
|
+
for (const key of keys) {
|
|
91
|
+
const problem = keyProblem(key);
|
|
92
|
+
if (problem !== undefined)
|
|
93
|
+
throw invalid(key, problem);
|
|
94
|
+
}
|
|
95
|
+
const listed = new Set(keys);
|
|
96
|
+
// the URLs a stylesheet may use: ../assets/<path> of a listed asset
|
|
97
|
+
const assetPaths = new Set((section.assets ?? []).map((k) => k.slice(`${KEY_PREFIX}assets/`.length)));
|
|
98
|
+
for (const path of [...files.keys()].sort()) {
|
|
99
|
+
if (path.startsWith(CLIENT_DIR) && !listed.has(`./${path}`)) {
|
|
100
|
+
throw invalid(`./${path}`, "the file is not listed in the plugin's client section");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const out = [];
|
|
104
|
+
let total = 0;
|
|
105
|
+
for (const key of keys) {
|
|
106
|
+
const data = files.get(key.slice(2));
|
|
107
|
+
if (data === undefined)
|
|
108
|
+
throw invalid(key, "the file is missing from the package");
|
|
109
|
+
const size = data.byteLength;
|
|
110
|
+
const limit = key === section.entry
|
|
111
|
+
? CLIENT_LIMITS.entryBytes
|
|
112
|
+
: styles.includes(key)
|
|
113
|
+
? CLIENT_LIMITS.styleBytes
|
|
114
|
+
: CLIENT_LIMITS.assetBytes;
|
|
115
|
+
if (size > limit)
|
|
116
|
+
throw invalid(key, `the file is ${size} bytes; the limit is ${limit}`);
|
|
117
|
+
total += size;
|
|
118
|
+
const integrity = sha384(data);
|
|
119
|
+
if (integrity !== section.integrity[key]) {
|
|
120
|
+
throw new ApiError("client.integrity_mismatch", `The plugin's client file ${key} does not match the integrity recorded in its plugin.json`, [{ file: key }]);
|
|
121
|
+
}
|
|
122
|
+
if (styles.includes(key)) {
|
|
123
|
+
const css = utf8(data);
|
|
124
|
+
if (css === undefined)
|
|
125
|
+
throw invalid(key, "the stylesheet is not valid UTF-8 text");
|
|
126
|
+
const problem = clientCssProblem(css, assetPaths);
|
|
127
|
+
if (problem !== undefined)
|
|
128
|
+
throw invalid(key, problem);
|
|
129
|
+
}
|
|
130
|
+
out.push({ key, data, size, integrity });
|
|
131
|
+
}
|
|
132
|
+
if (total > CLIENT_LIMITS.totalBytes) {
|
|
133
|
+
throw invalid("plugin.json", `the client files are ${total} bytes in total; the limit is ${CLIENT_LIMITS.totalBytes}`);
|
|
134
|
+
}
|
|
135
|
+
return { files: out, total };
|
|
136
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { LockService } from "../infra/lock/lock.service.js";
|
|
2
|
+
import type { KernelLogger } from "../log.js";
|
|
3
|
+
import { ClientReports } from "./client-reports.service.js";
|
|
4
|
+
/** The daily prune of client reports (`@LockedCron`). */
|
|
5
|
+
export declare const CLIENT_REPORTS_PRUNE_JOB = "client-reports-prune";
|
|
6
|
+
/** Deletes client reports not seen for 30 days (spec §10.4), once per tick across replicas; the worker role's. */
|
|
7
|
+
export declare class ClientReportsCron {
|
|
8
|
+
private readonly reports;
|
|
9
|
+
readonly lockService: LockService;
|
|
10
|
+
readonly log: KernelLogger;
|
|
11
|
+
constructor(reports: ClientReports, lockService: LockService, log: KernelLogger);
|
|
12
|
+
prune(): Promise<void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
8
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
9
|
+
};
|
|
10
|
+
import { Inject, Injectable } from "@nestjs/common";
|
|
11
|
+
import { LockService } from "../infra/lock/lock.service.js";
|
|
12
|
+
import { LockedCron } from "../infra/schedule/locked-cron.decorator.js";
|
|
13
|
+
import { KERNEL_LOGGER } from "../infra/tokens.js";
|
|
14
|
+
import { ClientReports } from "./client-reports.service.js";
|
|
15
|
+
/** The daily prune of client reports (`@LockedCron`). */
|
|
16
|
+
export const CLIENT_REPORTS_PRUNE_JOB = "client-reports-prune";
|
|
17
|
+
/** Deletes client reports not seen for 30 days (spec §10.4), once per tick across replicas; the worker role's. */
|
|
18
|
+
let ClientReportsCron = class ClientReportsCron {
|
|
19
|
+
reports;
|
|
20
|
+
lockService;
|
|
21
|
+
log;
|
|
22
|
+
constructor(reports, lockService, log) {
|
|
23
|
+
this.reports = reports;
|
|
24
|
+
this.lockService = lockService;
|
|
25
|
+
this.log = log;
|
|
26
|
+
}
|
|
27
|
+
async prune() {
|
|
28
|
+
const n = await this.reports.prune();
|
|
29
|
+
if (n > 0)
|
|
30
|
+
this.log.info("client reports pruned", { count: n });
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
__decorate([
|
|
34
|
+
LockedCron("50 3 * * *", { name: CLIENT_REPORTS_PRUNE_JOB })
|
|
35
|
+
], ClientReportsCron.prototype, "prune", null);
|
|
36
|
+
ClientReportsCron = __decorate([
|
|
37
|
+
Injectable(),
|
|
38
|
+
__param(0, Inject(ClientReports)),
|
|
39
|
+
__param(1, Inject(LockService)),
|
|
40
|
+
__param(2, Inject(KERNEL_LOGGER))
|
|
41
|
+
], ClientReportsCron);
|
|
42
|
+
export { ClientReportsCron };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { KernelDb } from "../infra/database/kernel-db.js";
|
|
2
|
+
import { TransactionHost } from "../infra/database/transaction-host.js";
|
|
3
|
+
import type { ReportBodyInput } from "./client.schemas.js";
|
|
4
|
+
import { ClientAccess } from "./client-access.service.js";
|
|
5
|
+
/** Reports older than this (by `last_at`) are deleted by the daily prune (spec §10.4). */
|
|
6
|
+
export declare const REPORT_RETENTION_DAYS = 30;
|
|
7
|
+
/**
|
|
8
|
+
* Crash and load reports of client code (client extensions spec §10.4): one row per (plugin,
|
|
9
|
+
* build, org, contribution, kind), upserted so a crash loop is one row with a count. A report is
|
|
10
|
+
* accepted only for a build the reporter could fetch ({@link ClientAccess.fetchable}), in an org
|
|
11
|
+
* an extension enabled there, and a `contribution` that is null or a component of that build's
|
|
12
|
+
* manifest, so nobody writes rows about plugins or orgs they cannot see and the key bounds the
|
|
13
|
+
* rows (a made-up contribution cannot add one); anything else is the same 404
|
|
14
|
+
* `client.not_found` the delivery route answers.
|
|
15
|
+
*/
|
|
16
|
+
export declare class ClientReports {
|
|
17
|
+
private readonly db;
|
|
18
|
+
readonly txHost: TransactionHost;
|
|
19
|
+
private readonly access;
|
|
20
|
+
constructor(db: KernelDb, txHost: TransactionHost, access: ClientAccess);
|
|
21
|
+
/** A report from a workspace page of org `orgId`. */
|
|
22
|
+
recordForOrg(accountId: string, orgId: string, body: ReportBodyInput): Promise<void>;
|
|
23
|
+
/** A report from the admin console (no org; super admins only, the caller checks that). */
|
|
24
|
+
recordForAdmin(accountId: string, body: ReportBodyInput): Promise<void>;
|
|
25
|
+
/** The build must be fetchable and the contribution null or one of its components. */
|
|
26
|
+
private check;
|
|
27
|
+
private upsert;
|
|
28
|
+
/** Deletes the reports last seen more than {@link REPORT_RETENTION_DAYS} days ago; the count. */
|
|
29
|
+
prune(): Promise<number>;
|
|
30
|
+
}
|