@jimhoyd/urlcode-admin 0.1.0-alpha.1

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 (47) hide show
  1. package/IMPLEMENTATION-STATUS.md +28 -0
  2. package/LICENSE +202 -0
  3. package/README.md +292 -0
  4. package/SECURITY.md +19 -0
  5. package/THREAT-MODEL.md +34 -0
  6. package/UX-REVIEW.md +61 -0
  7. package/dist/admin-account.d.ts +15 -0
  8. package/dist/admin-account.js +82 -0
  9. package/dist/admin-audit-export.d.ts +7 -0
  10. package/dist/admin-audit-export.js +38 -0
  11. package/dist/admin-copy.d.ts +64 -0
  12. package/dist/admin-copy.js +71 -0
  13. package/dist/admin-dashboard.d.ts +2 -0
  14. package/dist/admin-dashboard.js +14 -0
  15. package/dist/admin-detail.d.ts +14 -0
  16. package/dist/admin-detail.js +27 -0
  17. package/dist/admin-health.d.ts +24 -0
  18. package/dist/admin-health.js +33 -0
  19. package/dist/admin-presentation.d.ts +3 -0
  20. package/dist/admin-presentation.js +20 -0
  21. package/dist/admin-recovery.d.ts +24 -0
  22. package/dist/admin-recovery.js +78 -0
  23. package/dist/admin-reporting.d.ts +14 -0
  24. package/dist/admin-reporting.js +161 -0
  25. package/dist/admin-runtime.d.ts +16 -0
  26. package/dist/admin-runtime.js +22 -0
  27. package/dist/admin-screens.d.ts +32 -0
  28. package/dist/admin-screens.js +61 -0
  29. package/dist/admin-templates.d.ts +13 -0
  30. package/dist/admin-templates.js +76 -0
  31. package/dist/admin-ui.d.ts +65 -0
  32. package/dist/admin-ui.js +71 -0
  33. package/dist/admin-user-export.d.ts +3 -0
  34. package/dist/admin-user-export.js +57 -0
  35. package/dist/admin-users.d.ts +13 -0
  36. package/dist/admin-users.js +19 -0
  37. package/dist/admin.d.ts +37 -0
  38. package/dist/admin.js +304 -0
  39. package/dist/cli.d.ts +2 -0
  40. package/dist/cli.js +16 -0
  41. package/dist/index.d.ts +13 -0
  42. package/dist/index.js +6 -0
  43. package/dist/scaffold.d.ts +38 -0
  44. package/dist/scaffold.js +46 -0
  45. package/dist/support-banner.d.ts +11 -0
  46. package/dist/support-banner.js +43 -0
  47. package/package.json +56 -0
@@ -0,0 +1,46 @@
1
+ import { readFile, writeFile, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { initAuthentication } from '@jimhoyd/urlcode-auth';
4
+ const readme = `## Administration
5
+
6
+ The admin extension shares auth's operator service, CSRF key and explicit project revision. Its host entry references the \`service\`, \`csrfKey\` and \`projectSha256\` identifiers that auth's host setup defines; admin adds no key files, database or environment variables of its own. After bootstrapping and signing in as the first administrator, open /admin. Public registration is off. User invitations, account setup mail and impersonation require explicit sender callbacks; impersonation is disabled by default. Do not put operator modules or data/ into the app directory.`;
7
+ /** Describes admin's contribution to a composed project without writing anything. Requires the auth extension in the same host. */
8
+ export async function scaffold(request) {
9
+ for (const key of ['directory', 'project', 'hostFile'])
10
+ if (typeof request[key] !== 'string' || !request[key])
11
+ throw new Error(`Scaffold request needs an absolute ${key}`);
12
+ if (!request.names.includes('auth'))
13
+ throw new Error("Admin scaffold requires the auth extension: urlcode init --with auth,admin");
14
+ return {
15
+ name: 'admin',
16
+ extensions: { admin: { version: '1', config: {} } },
17
+ routes: { '/admin/*': { extension: 'admin', methods: ['GET', 'HEAD', 'POST'] } },
18
+ hostImports: ["import {adminExtension} from '@jimhoyd/urlcode-admin';"],
19
+ hostSetup: ['// Admin reuses service, csrfKey and projectSha256 from the auth setup above.'],
20
+ hostEntries: ["adminExtension({service, csrfKey, projectSha256, authMount: '/account'})"],
21
+ files: [],
22
+ readme,
23
+ nextSteps: ['Bootstrap the first administrator with `npx urlcode-auth bootstrap`, sign in at /account/login, then open /admin.', 'Configure sender callbacks before inviting users; impersonation stays disabled until explicitly enabled.'],
24
+ };
25
+ }
26
+ /** Separate operator host and route project; both extensions remain explicitly pinned. */
27
+ export async function initAdministration(directory) {
28
+ const created = await initAuthentication(directory);
29
+ try {
30
+ const admin = await scaffold({ directory: created.directory, project: created.project, hostFile: created.hostFile, names: ['auth', 'admin'] });
31
+ const document = { version: '1', extensions: { auth: { version: '1', config: { registration: 'off' } }, ...admin.extensions }, routes: { '/account/*': { extension: 'auth', methods: ['GET', 'HEAD', 'POST'] }, ...admin.routes, '/private': { respond: { text: 'Signed in' }, policies: { extensions: { auth: {} } } } } };
32
+ await writeFile(join(created.project, 'urlcode.yaml'), JSON.stringify(document, null, 2) + '\n');
33
+ const host = await readFile(created.hostFile, 'utf8');
34
+ const marker = 'extensions: [authExtension({service, csrfKey, projectSha256})]';
35
+ if (!host.includes(marker))
36
+ throw new Error('Incompatible auth scaffold');
37
+ await writeFile(created.hostFile, admin.hostImports.join('\n') + '\n' + host.replace(marker, `extensions: [authExtension({service, csrfKey, projectSha256}), ${admin.hostEntries.join(', ')}]`));
38
+ const instructions = await readFile(join(created.directory, 'README.md'), 'utf8');
39
+ await writeFile(join(created.directory, 'README.md'), instructions.replace('This starter includes auth only.', 'This starter includes auth and admin.').replace('/absolute/path/to/urlcode-auth', '/absolute/path/to/urlcode-auth /absolute/path/to/urlcode-admin') + '\n' + admin.readme + '\n');
40
+ return created;
41
+ }
42
+ catch (error) {
43
+ await rm(created.directory, { recursive: true, force: true });
44
+ throw error;
45
+ }
46
+ }
@@ -0,0 +1,11 @@
1
+ import type { Runtime } from '@jimhoyd/urlcode';
2
+ import type { AuthService } from '@jimhoyd/urlcode-auth';
3
+ export interface SupportBannerOptions {
4
+ service: Pick<AuthService, 'authenticate'>;
5
+ authMount?: string;
6
+ message?: string;
7
+ endLabel?: string;
8
+ maximumHtmlBytes?: number;
9
+ }
10
+ /** Required host integration for impersonation: route every response through this runtime wrapper. */
11
+ export declare function withSupportBanner(runtime: Runtime, options: SupportBannerOptions): Runtime;
@@ -0,0 +1,43 @@
1
+ import { escapeHtml } from '@jimhoyd/urlcode-ui';
2
+ /** Required host integration for impersonation: route every response through this runtime wrapper. */
3
+ export function withSupportBanner(runtime, options) {
4
+ const mount = options.authMount ?? '/account', maximum = options.maximumHtmlBytes ?? 1048576;
5
+ if (!/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(mount) || !Number.isSafeInteger(maximum) || maximum < 1024 || maximum > 4194304)
6
+ throw new Error('Invalid support banner configuration');
7
+ for (const value of [options.message, options.endLabel])
8
+ if (value !== undefined && (typeof value !== 'string' || !value || value.length > 512 || /[\x00-\x1f\x7f]/.test(value)))
9
+ throw new Error('Invalid support banner copy');
10
+ const banner = `<aside role="alert" aria-label="Support session" id="urlcode-support-banner"><strong>${escapeHtml(options.message ?? 'Support impersonation is active. Security changes are disabled.')}</strong> <a href="${escapeHtml(mount + '/account')}">${escapeHtml(options.endLabel ?? 'End support session')}</a></aside>`;
11
+ const handle = async (request) => {
12
+ const requestHeaders = new Headers(request.headers);
13
+ const cookies = requestHeaders.get('cookie') ?? '', candidates = cookies.split(';').map(value => value.trim()).filter(value => value.startsWith('__Host-urlcode-session='));
14
+ const token = cookies.length <= 8192 && candidates.length === 1 && (request.headerCounts?.cookie ?? 1) === 1 ? candidates[0].slice('__Host-urlcode-session='.length) : '';
15
+ const principal = /^[A-Za-z0-9_-]{43}$/.test(token) ? await options.service.authenticate(token) : null;
16
+ if (!principal?.impersonatorId)
17
+ return runtime.handle({ ...request, headers: requestHeaders });
18
+ const headers = new Headers(requestHeaders);
19
+ for (const name of ['if-none-match', 'if-modified-since', 'range', 'if-range', 'accept-encoding'])
20
+ headers.delete(name);
21
+ const result = await runtime.handle({ ...request, headers });
22
+ const output = result.headers.filter(([name]) => !['cache-control', 'cdn-cache-control', 'vercel-cdn-cache-control', 'surrogate-control', 'etag', 'last-modified', 'content-length', 'x-urlcode-support-session'].includes(name.toLowerCase()));
23
+ output.push(['cache-control', 'no-store'], ['cdn-cache-control', 'no-store'], ['x-urlcode-support-session', 'active']);
24
+ const type = output.find(([name]) => name.toLowerCase() === 'content-type')?.[1].split(';')[0]?.trim().toLowerCase();
25
+ if (result.status !== 304 && (request.method?.toUpperCase() === 'HEAD' || type !== 'text/html' || result.status < 200 || result.status === 204 || result.status >= 300 && result.status < 400))
26
+ return { ...result, headers: output };
27
+ const encoded = typeof result.body === 'string' ? Buffer.from(result.body) : Buffer.from(result.body ?? new Uint8Array());
28
+ let html;
29
+ try {
30
+ if (result.status === 304 || output.filter(([name]) => name.toLowerCase() === 'content-type').length !== 1 || encoded.byteLength > maximum || output.some(([name, value]) => name.toLowerCase() === 'content-encoding' && value !== 'identity'))
31
+ throw new Error('Unsupported response');
32
+ html = new TextDecoder('utf-8', { fatal: true }).decode(encoded);
33
+ }
34
+ catch {
35
+ return { status: 409, headers: [['content-type', 'text/html; charset=utf-8'], ['cache-control', 'no-store'], ['cdn-cache-control', 'no-store'], ['x-urlcode-support-session', 'active'], ['content-security-policy', "default-src 'none'; base-uri 'none'; frame-ancestors 'none'"], ['referrer-policy', 'no-referrer'], ['x-content-type-options', 'nosniff']], body: `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Support session</title></head><body>${banner}<p>This page cannot be displayed safely during a support session.</p></body></html>` };
36
+ }
37
+ html = /<body(?:\s[^>]*)?>/i.test(html) ? html.replace(/<body(?:\s[^>]*)?>/i, match => match + banner) : banner + html;
38
+ const { contentLength: _length, ...rest } = result;
39
+ return { ...rest, headers: output, body: new TextEncoder().encode(html) };
40
+ };
41
+ return new Proxy(runtime, { get(target, key) { if (key === 'handle')
42
+ return handle; const value = Reflect.get(target, key, target); return typeof value === 'function' ? value.bind(target) : value; } });
43
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@jimhoyd/urlcode-admin",
3
+ "version": "0.1.0-alpha.1",
4
+ "type": "module",
5
+ "description": "Administration console extension for URLCode, on top of the auth extension",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/jimhoyd-com/urlcode-admin.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "license": "Apache-2.0",
14
+ "engines": {
15
+ "node": ">=22.18.0"
16
+ },
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.build.json",
19
+ "typecheck": "tsc --noEmit",
20
+ "pretest": "node scripts/check-sqlite.mjs",
21
+ "test": "node --conditions=development --test test/*.test.ts",
22
+ "verify": "npm run typecheck && npm run build && npm test"
23
+ },
24
+ "exports": {
25
+ ".": {
26
+ "development": "./src/index.ts",
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "README.md",
34
+ "LICENSE",
35
+ "SECURITY.md",
36
+ "IMPLEMENTATION-STATUS.md",
37
+ "THREAT-MODEL.md",
38
+ "UX-REVIEW.md"
39
+ ],
40
+ "devDependencies": {
41
+ "@types/node": "26.5.1",
42
+ "typescript": "6.0.3"
43
+ },
44
+ "peerDependencies": {
45
+ "@jimhoyd/urlcode": ">=0.4.0-alpha.1 <0.5.0",
46
+ "@jimhoyd/urlcode-auth": ">=0.1.0-alpha.2 <0.2.0",
47
+ "@jimhoyd/urlcode-ui": ">=0.1.0-alpha.1 <0.2.0"
48
+ },
49
+ "bin": {
50
+ "urlcode-admin": "./dist/cli.js"
51
+ },
52
+ "homepage": "https://github.com/jimhoyd-com/urlcode-admin#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/jimhoyd-com/urlcode-admin/issues"
55
+ }
56
+ }