clankerbend 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.
- package/DEVELOPERS.md +6 -6
- package/README.md +31 -14
- package/apps/README.md +3 -3
- package/apps/sticky-notes/README.md +2 -2
- package/apps/sticky-notes/{onewhack.manifest.json → clankerbend.manifest.json} +1 -1
- package/apps/sticky-notes/package.json +3 -3
- package/apps/sticky-notes/public/app.js +42 -9
- package/apps/sticky-notes/src/sticky-notes-app.js +17 -21
- package/apps/vim-nav/README.md +22 -22
- package/apps/vim-nav/{onewhack.manifest.json → clankerbend.manifest.json} +4 -4
- package/apps/vim-nav/package.json +3 -3
- package/apps/vim-nav/public/app.js +44 -11
- package/apps/vim-nav/public/index.html +2 -2
- package/apps/vim-nav/server.mjs +2 -2
- package/apps/vim-nav/src/vim-nav-app.js +5 -5
- package/assets/clankerbend-banner.webp +0 -0
- package/assets/clankerbend-demo-thumbnail.webp +0 -0
- package/assets/clankerbend.svg +26 -0
- package/cli.mjs +22 -11
- package/docs/app-lifecycle.md +9 -9
- package/docs/app-manifest.md +4 -4
- package/docs/author-guide.md +5 -5
- package/docs/launcher-profiles.md +45 -10
- package/docs/protocol.md +248 -241
- package/host/README.md +4 -4
- package/host/src/app-registry.js +8 -6
- package/host/src/codex-desktop-cdp-adapter.js +85 -65
- package/host/src/codex-desktop-renderer-bridge.js +839 -270
- package/host/src/index.js +186 -34
- package/launch/accounts.mjs +360 -0
- package/launch/codex-mux-adapter.mjs +175 -0
- package/launch/profiles.mjs +40 -16
- package/launch/runtime-paths.mjs +9 -6
- package/launch/test-accounts.mjs +71 -0
- package/package.json +12 -10
- package/scripts/release-npm.mjs +1 -2
- package/server.mjs +21 -14
- package/assets/onewhack.jpg +0 -0
package/host/src/index.js
CHANGED
|
@@ -3,9 +3,9 @@ import http from "node:http";
|
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { extname, join, relative, resolve } from "node:path";
|
|
5
5
|
|
|
6
|
-
export const
|
|
6
|
+
export const CLANKERBEND_VERSION = "0.1";
|
|
7
7
|
|
|
8
|
-
export class
|
|
8
|
+
export class ClankerBendHttpError extends Error {
|
|
9
9
|
constructor(status, code, message, detail) {
|
|
10
10
|
super(message);
|
|
11
11
|
this.status = status;
|
|
@@ -15,7 +15,7 @@ export class OneWhackHttpError extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function httpError(status, code, message, detail) {
|
|
18
|
-
return new
|
|
18
|
+
return new ClankerBendHttpError(status, code, message, detail);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function createMockTranscriptAdapter(options = {}) {
|
|
@@ -91,15 +91,16 @@ export function mockAnchor(anchorId, order, inferredRole, textPreview) {
|
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
export class
|
|
94
|
+
export class ClankerBendHost {
|
|
95
95
|
constructor(options = {}) {
|
|
96
|
-
this.protocolVersion = options.protocolVersion ||
|
|
97
|
-
this.hostId = options.hostId || "onewill.
|
|
98
|
-
this.hostName = options.hostName || "
|
|
96
|
+
this.protocolVersion = options.protocolVersion || CLANKERBEND_VERSION;
|
|
97
|
+
this.hostId = options.hostId || "onewill.clankerbend.host";
|
|
98
|
+
this.hostName = options.hostName || "ClankerBend Host";
|
|
99
99
|
this.localDevInsecure = options.localDevInsecure === true;
|
|
100
100
|
this.token = this.localDevInsecure ? "" : (isNonEmptyString(options.token) ? options.token : createSessionToken());
|
|
101
101
|
this.runDir = options.runDir || null;
|
|
102
102
|
this.transcriptAdapter = options.transcriptAdapter || createMockTranscriptAdapter();
|
|
103
|
+
this.accountRegistry = options.accountRegistry || null;
|
|
103
104
|
this.apps = new Map();
|
|
104
105
|
this.sseClients = new Set();
|
|
105
106
|
this.actionResults = new Map();
|
|
@@ -157,6 +158,15 @@ export class OneWhackHost {
|
|
|
157
158
|
version: null,
|
|
158
159
|
error: "not started"
|
|
159
160
|
},
|
|
161
|
+
codexAccounts: this.accountRegistry
|
|
162
|
+
? {
|
|
163
|
+
available: true,
|
|
164
|
+
activeAccountId: null,
|
|
165
|
+
maxRunningDesktopInstances: 1,
|
|
166
|
+
switching: false,
|
|
167
|
+
...this.accountRegistry.list()
|
|
168
|
+
}
|
|
169
|
+
: null,
|
|
160
170
|
lastAction: null
|
|
161
171
|
};
|
|
162
172
|
}
|
|
@@ -266,6 +276,11 @@ export class OneWhackHost {
|
|
|
266
276
|
correlateItems: false,
|
|
267
277
|
approvals: false,
|
|
268
278
|
rollback: false
|
|
279
|
+
},
|
|
280
|
+
codexAccounts: {
|
|
281
|
+
available: Boolean(this.accountRegistry),
|
|
282
|
+
switch: Boolean(this.transcriptAdapter.switchTo),
|
|
283
|
+
adoptPrimary: Boolean(this.transcriptAdapter.adoptAsPrimary)
|
|
269
284
|
}
|
|
270
285
|
};
|
|
271
286
|
}
|
|
@@ -274,12 +289,12 @@ export class OneWhackHost {
|
|
|
274
289
|
const app = this.apps.get(appId);
|
|
275
290
|
if (!app || !this.state.host.url) return null;
|
|
276
291
|
const base = `${this.state.host.url.replace(/\/$/, "")}/apps/${encodeURIComponent(app.appId)}/`;
|
|
277
|
-
return this.token ? `${base}#
|
|
292
|
+
return this.token ? `${base}#clankerbend_token=${encodeURIComponent(this.token)}` : base;
|
|
278
293
|
}
|
|
279
294
|
|
|
280
295
|
hostManifest() {
|
|
281
296
|
return {
|
|
282
|
-
|
|
297
|
+
clankerbendVersion: this.protocolVersion,
|
|
283
298
|
hostId: this.hostId,
|
|
284
299
|
hostName: this.hostName,
|
|
285
300
|
capabilities: this.capabilities(),
|
|
@@ -307,7 +322,7 @@ export class OneWhackHost {
|
|
|
307
322
|
}
|
|
308
323
|
return cleanObject({
|
|
309
324
|
...loadedManifest,
|
|
310
|
-
|
|
325
|
+
clankerbendVersion: this.protocolVersion,
|
|
311
326
|
appId: app.appId,
|
|
312
327
|
name: app.name || app.appId,
|
|
313
328
|
version: app.version || loadedManifest.version,
|
|
@@ -323,8 +338,9 @@ export class OneWhackHost {
|
|
|
323
338
|
}
|
|
324
339
|
|
|
325
340
|
publicState() {
|
|
341
|
+
if (this.accountRegistry) this.state.codexAccounts = this.accountState();
|
|
326
342
|
return {
|
|
327
|
-
protocolName: "
|
|
343
|
+
protocolName: "clankerbend",
|
|
328
344
|
protocolVersion: this.protocolVersion,
|
|
329
345
|
sequence: this.state.sequence,
|
|
330
346
|
generatedAt: this.state.generatedAt,
|
|
@@ -361,6 +377,7 @@ export class OneWhackHost {
|
|
|
361
377
|
...this.appState(app.appId)
|
|
362
378
|
})),
|
|
363
379
|
appServer: this.state.appServer,
|
|
380
|
+
codexAccounts: this.state.codexAccounts || undefined,
|
|
364
381
|
lastAction: this.state.lastAction || undefined
|
|
365
382
|
};
|
|
366
383
|
}
|
|
@@ -481,26 +498,39 @@ export class OneWhackHost {
|
|
|
481
498
|
res.writeHead(204).end();
|
|
482
499
|
return;
|
|
483
500
|
}
|
|
484
|
-
if (url.pathname.startsWith("/
|
|
485
|
-
this.fail(res, 401, "unauthorized", "missing or invalid
|
|
501
|
+
if (url.pathname.startsWith("/clankerbend/") && !this.authorized(req)) {
|
|
502
|
+
this.fail(res, 401, "unauthorized", "missing or invalid ClankerBend token");
|
|
486
503
|
return;
|
|
487
504
|
}
|
|
488
505
|
|
|
489
|
-
if (url.pathname === "/
|
|
490
|
-
if (url.pathname === "/
|
|
491
|
-
if (url.pathname === "/
|
|
492
|
-
if (url.pathname === "/
|
|
493
|
-
if (url.pathname === "/
|
|
494
|
-
if (url.pathname === "/
|
|
495
|
-
if (url.pathname === "/
|
|
496
|
-
if (url.pathname === "/
|
|
497
|
-
if (url.pathname === "/
|
|
498
|
-
if (url.pathname === "/
|
|
499
|
-
if (url.pathname === "/
|
|
500
|
-
if (url.pathname === "/
|
|
501
|
-
if (url.pathname === "/
|
|
502
|
-
if (url.pathname === "/
|
|
503
|
-
if (url.pathname === "/
|
|
506
|
+
if (url.pathname === "/clankerbend/manifest" && req.method === "GET") return this.ok(res, this.hostManifest());
|
|
507
|
+
if (url.pathname === "/clankerbend/state" && req.method === "GET") return this.ok(res, this.publicState());
|
|
508
|
+
if (url.pathname === "/clankerbend/events" && req.method === "GET") return this.handleEvents(req, res);
|
|
509
|
+
if (url.pathname === "/clankerbend/apps" && req.method === "GET") return this.ok(res, { apps: this.appSummaries() });
|
|
510
|
+
if (url.pathname === "/clankerbend/panel/open" && req.method === "POST") return this.openPanelEndpoint(req, res);
|
|
511
|
+
if (url.pathname === "/clankerbend/transcript/scroll" && req.method === "POST") return this.scrollEndpoint(req, res);
|
|
512
|
+
if (url.pathname === "/clankerbend/transcript/highlight" && req.method === "POST") return this.highlightEndpoint(req, res);
|
|
513
|
+
if (url.pathname === "/clankerbend/transcript/highlight-range" && req.method === "POST") return this.highlightRangeEndpoint(req, res);
|
|
514
|
+
if (url.pathname === "/clankerbend/selection" && req.method === "POST") return this.selectionEndpoint(req, res);
|
|
515
|
+
if (url.pathname === "/clankerbend/overlay/open" && req.method === "POST") return this.overlayOpenEndpoint(req, res);
|
|
516
|
+
if (url.pathname === "/clankerbend/overlay/close" && req.method === "POST") return this.overlayCloseEndpoint(req, res);
|
|
517
|
+
if (url.pathname === "/clankerbend/composer/context" && req.method === "POST") return this.composerContextEndpoint(req, res);
|
|
518
|
+
if (url.pathname === "/clankerbend/composer/context/remove" && req.method === "POST") return this.composerContextRemoveEndpoint(req, res);
|
|
519
|
+
if (url.pathname === "/clankerbend/composer/draft" && req.method === "POST") return this.composerDraftEndpoint(req, res);
|
|
520
|
+
if (url.pathname === "/clankerbend/composer/submit" && req.method === "POST") return this.composerSubmitEndpoint(req, res);
|
|
521
|
+
if (url.pathname === "/clankerbend/codex/accounts" && req.method === "GET") return this.codexAccountsListEndpoint(res);
|
|
522
|
+
if (url.pathname === "/clankerbend/codex/accounts" && req.method === "POST") return this.codexAccountsCreateEndpoint(req, res);
|
|
523
|
+
if (url.pathname === "/clankerbend/codex/accounts/default" && req.method === "POST") return this.codexAccountsDefaultEndpoint(req, res);
|
|
524
|
+
if (url.pathname === "/clankerbend/codex/accounts/switch" && req.method === "POST") return this.codexAccountsSwitchEndpoint(req, res);
|
|
525
|
+
if (url.pathname === "/clankerbend/codex/primary/rollback" && req.method === "POST") return this.codexPrimaryRollbackEndpoint(req, res);
|
|
526
|
+
const accountRoute = this.parseCodexAccountRoute(url.pathname);
|
|
527
|
+
if (accountRoute && req.method === "POST" && accountRoute.tail === "/switch") return this.codexAccountSwitchEndpoint(accountRoute.accountId, res);
|
|
528
|
+
if (accountRoute && req.method === "POST" && accountRoute.tail === "/start") return this.codexAccountSwitchEndpoint(accountRoute.accountId, res);
|
|
529
|
+
if (accountRoute && req.method === "POST" && accountRoute.tail === "/focus") return this.codexAccountSwitchEndpoint(accountRoute.accountId, res);
|
|
530
|
+
if (accountRoute && req.method === "POST" && accountRoute.tail === "/set-default") return this.codexAccountSetDefaultEndpoint(accountRoute.accountId, res);
|
|
531
|
+
if (accountRoute && req.method === "POST" && accountRoute.tail === "/adopt-as-primary") return this.codexAccountAdoptEndpoint(accountRoute.accountId, res);
|
|
532
|
+
if (accountRoute && req.method === "DELETE") return this.codexAccountDeleteEndpoint(accountRoute.accountId, res);
|
|
533
|
+
if (accountRoute && req.method === "POST" && accountRoute.tail === "/delete") return this.codexAccountDeleteEndpoint(accountRoute.accountId, res);
|
|
504
534
|
|
|
505
535
|
const appRoute = this.parseAppRoute(url.pathname);
|
|
506
536
|
if (appRoute && req.method === "GET" && appRoute.tail === "/manifest") return this.ok(res, this.appManifest(appRoute.appId));
|
|
@@ -524,7 +554,7 @@ export class OneWhackHost {
|
|
|
524
554
|
}
|
|
525
555
|
|
|
526
556
|
parseAppRoute(pathname) {
|
|
527
|
-
if (!pathname.startsWith("/
|
|
557
|
+
if (!pathname.startsWith("/clankerbend/apps/")) return null;
|
|
528
558
|
const parts = pathname.split("/");
|
|
529
559
|
const appId = decodeURIComponent(parts[3] || "");
|
|
530
560
|
if (!appId) return null;
|
|
@@ -532,6 +562,107 @@ export class OneWhackHost {
|
|
|
532
562
|
return { appId, tail: `/${parts.slice(4).join("/")}` };
|
|
533
563
|
}
|
|
534
564
|
|
|
565
|
+
parseCodexAccountRoute(pathname) {
|
|
566
|
+
if (!pathname.startsWith("/clankerbend/codex/accounts/")) return null;
|
|
567
|
+
const parts = pathname.split("/");
|
|
568
|
+
const accountId = decodeURIComponent(parts[4] || "");
|
|
569
|
+
if (!accountId) return null;
|
|
570
|
+
return { accountId, tail: `/${parts.slice(5).join("/")}` };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
requireAccountRegistry() {
|
|
574
|
+
if (!this.accountRegistry) throw httpError(404, "not_found", "Codex account registry is unavailable");
|
|
575
|
+
return this.accountRegistry;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
accountState() {
|
|
579
|
+
if (typeof this.transcriptAdapter.accountState === "function") return this.transcriptAdapter.accountState();
|
|
580
|
+
return this.accountRegistry ? {
|
|
581
|
+
available: true,
|
|
582
|
+
activeAccountId: null,
|
|
583
|
+
maxRunningDesktopInstances: 1,
|
|
584
|
+
switching: false,
|
|
585
|
+
...this.accountRegistry.list()
|
|
586
|
+
} : null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
codexAccountsListEndpoint(res) {
|
|
590
|
+
this.requireAccountRegistry();
|
|
591
|
+
this.state.codexAccounts = this.accountState();
|
|
592
|
+
return this.ok(res, this.state.codexAccounts);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async codexAccountsCreateEndpoint(req, res) {
|
|
596
|
+
this.requireAccountRegistry();
|
|
597
|
+
const body = await readJsonObject(req, "account create request body");
|
|
598
|
+
const result = typeof this.transcriptAdapter.createAccount === "function"
|
|
599
|
+
? await this.transcriptAdapter.createAccount(body)
|
|
600
|
+
: this.accountRegistry.createManaged(body);
|
|
601
|
+
this.state.codexAccounts = this.accountState();
|
|
602
|
+
return this.ok(res, result, 201);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async codexAccountsDefaultEndpoint(req, res) {
|
|
606
|
+
const body = await readJsonObject(req, "account default request body");
|
|
607
|
+
if (!isNonEmptyString(body.accountId)) throw httpError(400, "bad_request", "accountId is required");
|
|
608
|
+
return this.codexAccountSetDefaultEndpoint(body.accountId, res);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async codexAccountsSwitchEndpoint(req, res) {
|
|
612
|
+
const body = await readJsonObject(req, "account switch request body");
|
|
613
|
+
if (!isNonEmptyString(body.accountId)) throw httpError(400, "bad_request", "accountId is required");
|
|
614
|
+
return this.codexAccountSwitchEndpoint(body.accountId, res);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async codexAccountSwitchEndpoint(accountId, res) {
|
|
618
|
+
this.requireAccountRegistry();
|
|
619
|
+
if (typeof this.transcriptAdapter.switchTo !== "function") {
|
|
620
|
+
throw httpError(409, "unsupported", "active adapter does not support Codex account switching");
|
|
621
|
+
}
|
|
622
|
+
const result = await this.transcriptAdapter.switchTo(accountId);
|
|
623
|
+
this.state.codexAccounts = this.accountState();
|
|
624
|
+
return this.ok(res, result);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async codexAccountSetDefaultEndpoint(accountId, res) {
|
|
628
|
+
this.requireAccountRegistry();
|
|
629
|
+
const result = typeof this.transcriptAdapter.setDefault === "function"
|
|
630
|
+
? await this.transcriptAdapter.setDefault(accountId)
|
|
631
|
+
: this.accountRegistry.setDefault(accountId);
|
|
632
|
+
this.state.codexAccounts = this.accountState();
|
|
633
|
+
return this.ok(res, result);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async codexAccountAdoptEndpoint(accountId, res) {
|
|
637
|
+
this.requireAccountRegistry();
|
|
638
|
+
if (typeof this.transcriptAdapter.adoptAsPrimary !== "function") {
|
|
639
|
+
throw httpError(409, "unsupported", "active adapter does not support primary adoption");
|
|
640
|
+
}
|
|
641
|
+
const result = await this.transcriptAdapter.adoptAsPrimary(accountId);
|
|
642
|
+
this.state.codexAccounts = this.accountState();
|
|
643
|
+
return this.ok(res, result);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async codexPrimaryRollbackEndpoint(req, res) {
|
|
647
|
+
this.requireAccountRegistry();
|
|
648
|
+
const body = await readJsonObject(req, "primary rollback request body");
|
|
649
|
+
if (typeof this.transcriptAdapter.rollbackPrimary !== "function") {
|
|
650
|
+
throw httpError(409, "unsupported", "active adapter does not support primary rollback");
|
|
651
|
+
}
|
|
652
|
+
const result = await this.transcriptAdapter.rollbackPrimary(body);
|
|
653
|
+
this.state.codexAccounts = this.accountState();
|
|
654
|
+
return this.ok(res, result);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async codexAccountDeleteEndpoint(accountId, res) {
|
|
658
|
+
this.requireAccountRegistry();
|
|
659
|
+
const result = typeof this.transcriptAdapter.deleteAccount === "function"
|
|
660
|
+
? await this.transcriptAdapter.deleteAccount(accountId)
|
|
661
|
+
: this.accountRegistry.deleteManaged(accountId);
|
|
662
|
+
this.state.codexAccounts = this.accountState();
|
|
663
|
+
return this.ok(res, result);
|
|
664
|
+
}
|
|
665
|
+
|
|
535
666
|
async actionEndpoint(appId, req, res) {
|
|
536
667
|
const app = this.requireApp(appId);
|
|
537
668
|
const body = await readJsonObject(req, "action request body");
|
|
@@ -594,7 +725,7 @@ export class OneWhackHost {
|
|
|
594
725
|
this.state.panel.error = null;
|
|
595
726
|
} else {
|
|
596
727
|
this.state.panel.status = "waiting";
|
|
597
|
-
this.state.panel.error = result?.error || "Open a Codex thread, then try opening the
|
|
728
|
+
this.state.panel.error = result?.error || "Open a Codex thread, then try opening the ClankerBend panel again.";
|
|
598
729
|
}
|
|
599
730
|
this.touchAndBroadcast();
|
|
600
731
|
return this.ok(res, result || { ok: false, error: "panel unavailable" });
|
|
@@ -973,10 +1104,17 @@ export class OneWhackHost {
|
|
|
973
1104
|
res.writeHead(404).end("not found");
|
|
974
1105
|
return;
|
|
975
1106
|
}
|
|
976
|
-
|
|
977
|
-
|
|
1107
|
+
const contentType = mimeType(filePath);
|
|
1108
|
+
const headers = {
|
|
1109
|
+
"content-type": contentType,
|
|
978
1110
|
"cache-control": "no-store"
|
|
979
|
-
}
|
|
1111
|
+
};
|
|
1112
|
+
if (this.token && contentType.startsWith("text/html")) {
|
|
1113
|
+
res.writeHead(200, headers);
|
|
1114
|
+
res.end(injectAppToken(readFileSync(filePath, "utf8"), this.token));
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
res.writeHead(200, headers);
|
|
980
1118
|
createReadStream(filePath).pipe(res);
|
|
981
1119
|
}
|
|
982
1120
|
|
|
@@ -1023,7 +1161,7 @@ export class OneWhackHost {
|
|
|
1023
1161
|
|
|
1024
1162
|
applyCors(res) {
|
|
1025
1163
|
res.setHeader("access-control-allow-origin", "*");
|
|
1026
|
-
res.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
|
|
1164
|
+
res.setHeader("access-control-allow-methods", "GET,POST,DELETE,OPTIONS");
|
|
1027
1165
|
res.setHeader("access-control-allow-headers", "authorization,content-type");
|
|
1028
1166
|
}
|
|
1029
1167
|
}
|
|
@@ -1134,6 +1272,20 @@ export function createSessionToken() {
|
|
|
1134
1272
|
return randomBytes(32).toString("base64url");
|
|
1135
1273
|
}
|
|
1136
1274
|
|
|
1275
|
+
function injectAppToken(html, token) {
|
|
1276
|
+
const script = `<meta name="clankerbend-token" content="${escapeHtmlAttribute(token)}">\n <script>window.__CLANKERBEND_TOKEN=${JSON.stringify(token)};</script>`;
|
|
1277
|
+
return /<head[^>]*>/i.test(html)
|
|
1278
|
+
? html.replace(/<head[^>]*>/i, (match) => `${match}\n ${script}`)
|
|
1279
|
+
: `${script}\n${html}`;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function escapeHtmlAttribute(value) {
|
|
1283
|
+
return String(value)
|
|
1284
|
+
.replace(/&/g, "&")
|
|
1285
|
+
.replace(/"/g, """)
|
|
1286
|
+
.replace(/</g, "<");
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1137
1289
|
function requirePermission(app, permission) {
|
|
1138
1290
|
if (app.permissions?.[permission] === false) {
|
|
1139
1291
|
throw httpError(403, "forbidden", `${app.appId} lacks ${permission}`);
|