@jimhoyd/urlcode-admin 0.1.0-alpha.2 → 0.1.0-alpha.3

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.
@@ -1,6 +1,6 @@
1
1
  # Admin implementation status
2
2
 
3
- Status: `0.1.0-alpha.1`, the first npm alpha, published through the tag-driven release workflow after core `0.4.0-alpha.1`, ui `0.1.0-alpha.1` and auth `0.1.0-alpha.1` (core issue #78). The implemented admin workflows and reviewed shared-presentation work are merged to main. Production release validation remains separate. Cross-repository acceptance: https://github.com/jimhoyd-com/urlcode/issues/58. Core extension integration PR #59 is merged. Auth owns identities, sessions and transactional authority checks; this package owns the trusted administration interface.
3
+ Status: `0.1.0-alpha.3`, published through the tag-driven release workflow after core `0.4.0-alpha.2`, ui `0.1.0-alpha.5` and auth `0.1.0-alpha.3`. It requires `@jimhoyd/urlcode` 0.4.0-alpha.2 or newer (`src/admin.ts` loads project hooks through `ExtensionActivation.root`) and `@jimhoyd/urlcode-ui` 0.1.0-alpha.5 or newer (whose hand-copied `ExtensionActivation` first carries `root`). It supersedes `0.1.0-alpha.2`, the first working npm alpha (`0.1.0-alpha.1` was blocked by npm's registry after an earlier out-of-band publish attempt and could never be used), and carries the work merged since — the trusted project-level lifecycle hooks and the `ExtensionActivation.root` fixture fixes for core 0.4.0-alpha.2 (core issue #78). The implemented admin workflows and reviewed shared-presentation work are merged to main. Production release validation remains separate. Cross-repository acceptance: https://github.com/jimhoyd-com/urlcode/issues/58. Core extension integration PR #59 is merged. Auth owns identities, sessions and transactional authority checks; this package owns the trusted administration interface.
4
4
 
5
5
  Implemented: scoped internal authorization; no impersonated admin access; fresh, reasoned mutations; account search, compound filters, sorting, stable live pagination, details, setup invitation and bounded export; audited full-email reveal; lock/unlock/role assignment; global and individual session revocation; role definitions; audit pagination; registration approval; dual-approval security cases with notes/closure; opt-in short-lived impersonation with required notice; shared safe presentation; auth/admin initialization with private operator storage.
6
6
 
package/README.md CHANGED
@@ -13,7 +13,7 @@ npm install @jimhoyd/urlcode @jimhoyd/urlcode-ui @jimhoyd/urlcode-auth @jimhoyd/
13
13
  npx urlcode init my-site --with auth,admin
14
14
  ```
15
15
 
16
- `@jimhoyd/urlcode-admin` is published to npm as an alpha (`0.1.0-alpha.1`). Alpha releases can change exported names, the console's routes and the scaffold output between versions without a deprecation period; pin exact versions in an operator directory and read the release notes before upgrading. The package declares its peers by version range (`@jimhoyd/urlcode >=0.4.0-alpha.1 <0.5.0`, `@jimhoyd/urlcode-ui` and `@jimhoyd/urlcode-auth >=0.1.0-alpha.1 <0.2.0`), so install all four together; npm resolves them from the registry. Every release is built by the tag-driven [release workflow](.github/workflows/release.yml), signed with a GitHub attestation and published through npm trusted publishing, so `gh attestation verify jimhoyd-urlcode-admin-<version>.tgz --repo jimhoyd-com/urlcode-admin` and `npm audit signatures` can check what you downloaded. Publishing is still not a security review, real-provider deployment evidence or an accessibility certification.
16
+ `@jimhoyd/urlcode-admin` is published to npm as an alpha (`0.1.0-alpha.3`). Alpha releases can change exported names, the console's routes and the scaffold output between versions without a deprecation period; pin exact versions in an operator directory and read the release notes before upgrading. The package declares its peers by version range (`@jimhoyd/urlcode >=0.4.0-alpha.1 <0.5.0`, `@jimhoyd/urlcode-ui >=0.1.0-alpha.1 <0.2.0` and `@jimhoyd/urlcode-auth >=0.1.0-alpha.2 <0.2.0`), so install all four together; npm resolves them from the registry. Every release is built by the tag-driven [release workflow](.github/workflows/release.yml), signed with a GitHub attestation and published through npm trusted publishing, so `gh attestation verify jimhoyd-urlcode-admin-<version>.tgz --repo jimhoyd-com/urlcode-admin` and `npm audit signatures` can check what you downloaded. Publishing is still not a security review, real-provider deployment evidence or an accessibility certification.
17
17
 
18
18
  ## Build from reviewed local repositories
19
19
 
@@ -67,6 +67,34 @@ The snippet is an integration fragment; use the auth scaffold's private key/serv
67
67
 
68
68
  Administrative actions authenticate internally even without an extra route policy. Missing sessions or administrative permissions receive 404 at the console gate. Use operator role declarations with the actual permissions exported by this implementation: `auth.users.read`, `auth.users.reveal`, `auth.users.export`, `auth.users.manage`, `auth.users.create`, `auth.sessions.manage`, `auth.roles.read`, `auth.audit.read`, `auth.cases.read`, `auth.cases.manage`, and `auth.users.impersonate`. `*` grants full operator-defined administrator permissions. Do not copy the proposal's separate `admin.*` permission names and expect them to work automatically.
69
69
 
70
+ ## Project-level lifecycle hooks
71
+
72
+ A project can name its own function to run at three admin lifecycle points, using the same `hooks` shape core documents for extensions generally (`docs/EXTENSIONS.md`, "Project-level lifecycle hooks", in [`urlcode`](https://github.com/jimhoyd-com/urlcode)) — a bare source path (default export), or an explicit `{source, export}`, resolved relative to the project root:
73
+
74
+ ```yaml
75
+ extensions:
76
+ admin:
77
+ version: '1'
78
+ config:
79
+ hooks:
80
+ beforeRoleChange:
81
+ source: ./hooks/role-change.mjs
82
+ onRegistrationApproved:
83
+ source: ./hooks/registration-approved.mjs
84
+ export: onApproved
85
+ onAccountStatusChanged: ./hooks/account-status.mjs
86
+ ```
87
+
88
+ | Hook | Fires | Input | Verdict |
89
+ |---|---|---|---|
90
+ | `beforeRoleChange` | Before an administrator's role change is applied (`/users/roles`) | `{accountId, currentRoles, requestedRoles, actorId, reason}` | `{allow: boolean, reason?: string}` — `allow: false` blocks the change before it reaches the auth service, and the request fails with 403 |
91
+ | `onRegistrationApproved` | After a waitlisted registration request is approved (`/registrations/approve`) | `{requestId, accountId, email, actorId, reason}` | none (side effect only) |
92
+ | `onAccountStatusChanged` | After an account is locked or unlocked (`/users/status`) | `{accountId, status: 'active' \| 'locked', actorId, reason}` | none (side effect only) |
93
+
94
+ **Trust model: no special case.** These hooks are first-party project code and run trusted, in-process, exactly like the general trusted-by-default rule for `function`/`middleware` routes (`docs/SPIKE-DEFAULT-TRUST-MODEL.md` in `urlcode`). This package does not implement sandboxed hook execution yet — that needs a core dispatch primitive extensions do not have ([jimhoyd-com/urlcode#151](https://github.com/jimhoyd-com/urlcode/issues/151)). A hook that declares `sandbox: true` is rejected explicitly, during activation, with an error naming the hook — never silently run trusted and never ignored.
95
+
96
+ A missing hook module, or a named export that is not a function, also fails activation (not the first request that would have used it). Registration approval, role assignment and lock/unlock cover the lifecycle points with existing, unambiguous admin actions today; registration rejection, session revocation, impersonation start/end and bulk actions have no hook yet and are tracked as follow-up work in [jimhoyd-com/urlcode-admin#32](https://github.com/jimhoyd-com/urlcode-admin/issues/32).
97
+
70
98
  ## Operating the console
71
99
 
72
100
  Bootstrap the first administrator through auth's operator CLI using JSON stdin. Writes require recent authentication and a reason; role/status/session changes run through the auth service's transactional authority checks. Roles themselves remain operator configuration. Masked lists, pagination, permission-filtered navigation and audit records help limit routine exposure.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Project-level lifecycle hooks: the shape documented in core's
3
+ * docs/EXTENSIONS.md ("Project-level lifecycle hooks") — a `hooks` block in
4
+ * the extension's own `config` naming a project function per lifecycle
5
+ * point, using the same source shape `function`/`middleware` routes already
6
+ * use (a bare string, or `{source, export}`), resolved relative to
7
+ * `ExtensionActivation.root`.
8
+ *
9
+ * Trust model: these hooks are first-party project code and run trusted,
10
+ * in-process, exactly like the general trusted-by-default rule for
11
+ * `function`/`middleware` routes (docs/SPIKE-DEFAULT-TRUST-MODEL.md). This
12
+ * package does not implement sandboxed hook execution yet — that requires a
13
+ * core dispatch primitive extensions do not have (jimhoyd-com/urlcode#151).
14
+ * A hook that declares `sandbox: true` is rejected explicitly at activation
15
+ * time (see `loadAdminHooks` below); it is never silently run trusted.
16
+ */
17
+ /** A hook reference: a bare source path (default export), or an explicit `{source, export}`. */
18
+ export interface HookDefinition {
19
+ source: string;
20
+ export?: string;
21
+ sandbox?: boolean;
22
+ }
23
+ export type HookConfig = string | HookDefinition;
24
+ export interface AdminHooksConfig {
25
+ /**
26
+ * Pre-action, veto-capable: called before an administrator's role change
27
+ * is applied. Returning `{allow: false}` blocks the change; the
28
+ * operation never reaches the auth service.
29
+ */
30
+ beforeRoleChange?: HookConfig;
31
+ /** Post-action, side-effect only: called after a registration request is approved. */
32
+ onRegistrationApproved?: HookConfig;
33
+ /** Post-action, side-effect only: called after an account is locked or unlocked. */
34
+ onAccountStatusChanged?: HookConfig;
35
+ }
36
+ export declare const adminHooksSchema: {
37
+ readonly type: "object";
38
+ readonly additionalProperties: false;
39
+ readonly properties: {
40
+ readonly beforeRoleChange: {
41
+ readonly oneOf: readonly [{
42
+ readonly type: "string";
43
+ readonly minLength: 1;
44
+ readonly maxLength: 1024;
45
+ }, {
46
+ readonly type: "object";
47
+ readonly additionalProperties: false;
48
+ readonly properties: {
49
+ readonly source: {
50
+ readonly type: "string";
51
+ readonly minLength: 1;
52
+ readonly maxLength: 1024;
53
+ };
54
+ readonly export: {
55
+ readonly type: "string";
56
+ readonly pattern: "^[A-Za-z_][A-Za-z0-9_]*$";
57
+ };
58
+ readonly sandbox: {
59
+ readonly type: "boolean";
60
+ };
61
+ };
62
+ readonly required: readonly ["source"];
63
+ }];
64
+ };
65
+ readonly onRegistrationApproved: {
66
+ readonly oneOf: readonly [{
67
+ readonly type: "string";
68
+ readonly minLength: 1;
69
+ readonly maxLength: 1024;
70
+ }, {
71
+ readonly type: "object";
72
+ readonly additionalProperties: false;
73
+ readonly properties: {
74
+ readonly source: {
75
+ readonly type: "string";
76
+ readonly minLength: 1;
77
+ readonly maxLength: 1024;
78
+ };
79
+ readonly export: {
80
+ readonly type: "string";
81
+ readonly pattern: "^[A-Za-z_][A-Za-z0-9_]*$";
82
+ };
83
+ readonly sandbox: {
84
+ readonly type: "boolean";
85
+ };
86
+ };
87
+ readonly required: readonly ["source"];
88
+ }];
89
+ };
90
+ readonly onAccountStatusChanged: {
91
+ readonly oneOf: readonly [{
92
+ readonly type: "string";
93
+ readonly minLength: 1;
94
+ readonly maxLength: 1024;
95
+ }, {
96
+ readonly type: "object";
97
+ readonly additionalProperties: false;
98
+ readonly properties: {
99
+ readonly source: {
100
+ readonly type: "string";
101
+ readonly minLength: 1;
102
+ readonly maxLength: 1024;
103
+ };
104
+ readonly export: {
105
+ readonly type: "string";
106
+ readonly pattern: "^[A-Za-z_][A-Za-z0-9_]*$";
107
+ };
108
+ readonly sandbox: {
109
+ readonly type: "boolean";
110
+ };
111
+ };
112
+ readonly required: readonly ["source"];
113
+ }];
114
+ };
115
+ };
116
+ };
117
+ /** Typed verdict for a pre-action hook that can veto. */
118
+ export interface HookVerdict {
119
+ allow: boolean;
120
+ reason?: string;
121
+ }
122
+ export interface RoleChangeInput {
123
+ accountId: string;
124
+ currentRoles: readonly string[];
125
+ requestedRoles: readonly string[];
126
+ actorId: string;
127
+ reason: string;
128
+ }
129
+ export interface RegistrationApprovedInput {
130
+ requestId: string;
131
+ accountId: string;
132
+ email: string;
133
+ actorId: string;
134
+ reason: string;
135
+ }
136
+ export interface AccountStatusChangedInput {
137
+ accountId: string;
138
+ status: 'active' | 'locked';
139
+ actorId: string;
140
+ reason: string;
141
+ }
142
+ export interface LoadedAdminHooks {
143
+ beforeRoleChange?: (input: RoleChangeInput) => HookVerdict | Promise<HookVerdict>;
144
+ onRegistrationApproved?: (input: RegistrationApprovedInput) => void | Promise<void>;
145
+ onAccountStatusChanged?: (input: AccountStatusChangedInput) => void | Promise<void>;
146
+ }
147
+ /**
148
+ * Resolves and imports every declared hook module against `root`
149
+ * (`ExtensionActivation.root`), trusted and in-process. Fails fast: a
150
+ * missing module, a missing/non-function export, or `sandbox: true` throws
151
+ * here, during activation, so a broken or unsupported hook never reaches a
152
+ * live request.
153
+ */
154
+ export declare function loadAdminHooks(config: Readonly<Record<string, unknown>>, root: string): Promise<LoadedAdminHooks>;
@@ -0,0 +1,69 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { resolve } from 'node:path';
10
+ import { pathToFileURL } from 'node:url';
11
+ /** JSON Schema fragment for the `hooks` block, merged into the extension's own config schema. */
12
+ const hookDefinitionSchema = {
13
+ oneOf: [
14
+ { type: 'string', minLength: 1, maxLength: 1024 },
15
+ {
16
+ type: 'object',
17
+ additionalProperties: false,
18
+ properties: {
19
+ source: { type: 'string', minLength: 1, maxLength: 1024 },
20
+ export: { type: 'string', pattern: '^[A-Za-z_][A-Za-z0-9_]*$' },
21
+ sandbox: { type: 'boolean' },
22
+ },
23
+ required: ['source'],
24
+ },
25
+ ],
26
+ };
27
+ export const adminHooksSchema = {
28
+ type: 'object',
29
+ additionalProperties: false,
30
+ properties: {
31
+ beforeRoleChange: hookDefinitionSchema,
32
+ onRegistrationApproved: hookDefinitionSchema,
33
+ onAccountStatusChanged: hookDefinitionSchema,
34
+ },
35
+ };
36
+ const HOOK_NAMES = ['beforeRoleChange', 'onRegistrationApproved', 'onAccountStatusChanged'];
37
+ /**
38
+ * Resolves and imports every declared hook module against `root`
39
+ * (`ExtensionActivation.root`), trusted and in-process. Fails fast: a
40
+ * missing module, a missing/non-function export, or `sandbox: true` throws
41
+ * here, during activation, so a broken or unsupported hook never reaches a
42
+ * live request.
43
+ */
44
+ export async function loadAdminHooks(config, root) {
45
+ const hooks = (config.hooks ?? {});
46
+ const loaded = {};
47
+ for (const name of HOOK_NAMES) {
48
+ const raw = hooks[name];
49
+ if (raw === undefined)
50
+ continue;
51
+ const definition = typeof raw === 'string' ? { source: raw } : raw;
52
+ if (definition.sandbox === true)
53
+ throw new Error(`hook ${name}: sandbox: true is not yet supported for project-level hooks, see jimhoyd-com/urlcode-admin#32`);
54
+ const exportName = definition.export || 'default';
55
+ const modulePath = resolve(root, definition.source);
56
+ let mod;
57
+ try {
58
+ mod = await import(__rewriteRelativeImportExtension(pathToFileURL(modulePath).href));
59
+ }
60
+ catch (error) {
61
+ throw new Error(`hook ${name}: failed to load module "${definition.source}": ${error instanceof Error ? error.message : String(error)}`);
62
+ }
63
+ const fn = mod[exportName];
64
+ if (typeof fn !== 'function')
65
+ throw new Error(`hook ${name}: export "${exportName}" of "${definition.source}" is not a function`);
66
+ loaded[name] = fn;
67
+ }
68
+ return loaded;
69
+ }
package/dist/admin.js CHANGED
@@ -12,17 +12,21 @@ import { exportAuditRange } from "./admin-audit-export.js";
12
12
  import { createHealthReader } from "./admin-health.js";
13
13
  import { maskEmail, sessionFilters, userFilters, userFilterKeys, auditFilters, selectedNames, selectedAccounts, usersCsv } from "./admin-reporting.js";
14
14
  import { AuthHttp, AuthHttpError, formField as baseField, jsonResponse, readFields, wantsJson, hasPermission } from '@jimhoyd/urlcode-auth';
15
+ import { adminHooksSchema, loadAdminHooks } from "./admin-hooks.js";
15
16
  const defaultPresentation = createAdminPresentation();
16
- const schema = { type: 'object', additionalProperties: false, properties: {} };
17
+ const schema = { type: 'object', additionalProperties: false, properties: { hooks: adminHooksSchema } };
17
18
  const permissions = ['auth.users.reveal', 'auth.audit.export', 'auth.health.read', 'auth.cases.read', 'auth.cases.manage', 'auth.users.impersonate', 'auth.users.export', 'auth.users.create', 'auth.users.read', 'auth.users.manage', 'auth.audit.read', 'auth.sessions.manage', 'auth.roles.read'];
18
19
  export function adminExtension(options) {
19
20
  const authMount = options.authMount || '/account';
20
21
  if (!/^\/[A-Za-z0-9/_-]*$/.test(authMount) || authMount.includes('//'))
21
22
  throw new Error('Invalid auth mount');
22
23
  return { name: 'admin', version: '1', projectSha256: options.projectSha256, targets: ['node'], schema, credentialHeaders: ['cookie', 'authorization', 'x-csrf-token'],
23
- activate(_config, context) {
24
+ async activate(config, context) {
24
25
  if (context.mounts.length !== 1)
25
26
  throw new Error('Admin requires exactly one mount');
27
+ // Fails fast during activation: a missing/broken hook module or an
28
+ // unsupported `sandbox: true` throws here, never on first request.
29
+ const hooks = await loadAdminHooks(config, context.root);
26
30
  const readHealth = options.health ? createHealthReader(options.health) : undefined;
27
31
  const mount = context.mounts[0], http = new AuthHttp({ origin: context.origin, csrfKey: options.csrfKey }), service = options.service;
28
32
  const accounts = createAdminAccount({ service, ...(options.sendAccountAdministration ? { sendAccountAdministration: options.sendAccountAdministration } : {}) }, http, mount);
@@ -268,7 +272,9 @@ export function adminExtension(options) {
268
272
  }
269
273
  else if (path === '/registrations/approve') {
270
274
  requirePermission(principal, 'auth.users.manage');
271
- await service.approveRegistration({ actorToken: token, requestId: fields.requestId || '', reason: fields.reason });
275
+ const approved = await service.approveRegistration({ actorToken: token, requestId: fields.requestId || '', reason: fields.reason });
276
+ if (hooks.onRegistrationApproved)
277
+ await hooks.onRegistrationApproved({ requestId: fields.requestId || '', accountId: approved.id, email: approved.email, actorId: principal.id, reason: fields.reason || '' });
272
278
  }
273
279
  else if (path === '/invitations') {
274
280
  requirePermission(principal, 'auth.users.create');
@@ -280,13 +286,22 @@ export function adminExtension(options) {
280
286
  else if (path === '/users/roles') {
281
287
  requirePermission(principal, 'auth.users.manage');
282
288
  const roles = (fields.roles || '').split(',').map(role => role.trim()).filter(Boolean);
283
- await service.adminSetRoles({ actorToken: token, accountId: fields.accountId || '', roles, reason: fields.reason });
289
+ const accountId = fields.accountId || '';
290
+ if (hooks.beforeRoleChange) {
291
+ const current = await service.getUser(accountId);
292
+ const verdict = await hooks.beforeRoleChange({ accountId, currentRoles: current?.roles ?? [], requestedRoles: roles, actorId: principal.id, reason: fields.reason || '' });
293
+ if (!verdict?.allow)
294
+ throw new AuthHttpError(403, verdict?.reason || 'Role change rejected by project hook');
295
+ }
296
+ await service.adminSetRoles({ actorToken: token, accountId, roles, reason: fields.reason });
284
297
  }
285
298
  else if (path === '/users/status') {
286
299
  requirePermission(principal, 'auth.users.manage');
287
300
  if (fields.status !== 'active' && fields.status !== 'locked')
288
301
  throw new AuthHttpError(400, 'Invalid status');
289
302
  await service.adminSetStatus({ actorToken: token, accountId: fields.accountId || '', status: fields.status, reason: fields.reason });
303
+ if (hooks.onAccountStatusChanged)
304
+ await hooks.onAccountStatusChanged({ accountId: fields.accountId || '', status: fields.status, actorId: principal.id, reason: fields.reason || '' });
290
305
  }
291
306
  else if (path === '/sessions/revoke') {
292
307
  requirePermission(principal, 'auth.sessions.manage');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jimhoyd/urlcode-admin",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.3",
4
4
  "type": "module",
5
5
  "description": "Administration console extension for URLCode, on top of the auth extension",
6
6
  "repository": {
@@ -42,9 +42,9 @@
42
42
  "typescript": "6.0.3"
43
43
  },
44
44
  "peerDependencies": {
45
- "@jimhoyd/urlcode": ">=0.4.0-alpha.1 <0.5.0",
45
+ "@jimhoyd/urlcode": ">=0.4.0-alpha.2 <0.5.0",
46
46
  "@jimhoyd/urlcode-auth": ">=0.1.0-alpha.2 <0.2.0",
47
- "@jimhoyd/urlcode-ui": ">=0.1.0-alpha.1 <0.2.0"
47
+ "@jimhoyd/urlcode-ui": ">=0.1.0-alpha.5 <0.2.0"
48
48
  },
49
49
  "bin": {
50
50
  "urlcode-admin": "./dist/cli.js"