@hyperfixation/admin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/budget.d.ts +55 -0
- package/dist/budget.js +99 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/machinery.d.ts +18 -0
- package/dist/machinery.js +29 -0
- package/dist/resource.d.ts +62 -0
- package/dist/resource.js +75 -0
- package/dist/router.d.ts +50 -0
- package/dist/router.js +69 -0
- package/dist/users.d.ts +10 -0
- package/dist/users.js +17 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Graham Lutz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/budget.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type RequireSession } from "@hyperfixation/auth";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
/** The audit row's `action`, alongside `app.pause` and `app.resume`. */
|
|
4
|
+
export declare const BUDGET_SET_OPERATION = "app.budget_set";
|
|
5
|
+
/** One line per change; the next gate's refusal will be asked about. */
|
|
6
|
+
export declare const BUDGET_SET_MARKER = "hf-admin: budget set";
|
|
7
|
+
/** A budget that is not a finite, non-negative number. Refused before any lock is taken. */
|
|
8
|
+
export declare class InvalidBudget extends Error {
|
|
9
|
+
readonly period: string;
|
|
10
|
+
readonly budgetUsd: number;
|
|
11
|
+
constructor(period: string, budgetUsd: number);
|
|
12
|
+
}
|
|
13
|
+
/** No row for that period yet. The first gate of a month creates it; nothing else does. */
|
|
14
|
+
export declare class UnknownBudgetPeriod extends Error {
|
|
15
|
+
readonly period: string;
|
|
16
|
+
constructor(period: string);
|
|
17
|
+
}
|
|
18
|
+
export interface SetBudgetOptions {
|
|
19
|
+
/** The `YYYY-MM` primary key. Only this row is touched. */
|
|
20
|
+
period: string;
|
|
21
|
+
budgetUsd: number;
|
|
22
|
+
/** The admin who did it. Null only for a change driven from outside a session. */
|
|
23
|
+
actorId?: string | null;
|
|
24
|
+
reason?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface SetBudgetResult {
|
|
27
|
+
period: string;
|
|
28
|
+
/** As stored, so a caller sees what `numeric(12,4)` kept rather than what it asked for. */
|
|
29
|
+
budgetUsd: string;
|
|
30
|
+
previousBudgetUsd: string;
|
|
31
|
+
spentUsd: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Sets one period's ceiling. The next gate reads it: nothing caches the budget, so the change
|
|
35
|
+
* lands on the next `llm.run` and not on any call already past its gate.
|
|
36
|
+
*
|
|
37
|
+
* A budget **below** what the period has already spent is allowed. It is not a correction but
|
|
38
|
+
* the kill-lever: the next gate compares `spent + reserved + estimate > budget` and refuses
|
|
39
|
+
* every further call for the period, which is what an admin watching a runaway month wants.
|
|
40
|
+
* `spent_usd` is never touched — the money is spent either way.
|
|
41
|
+
*
|
|
42
|
+
* `hf_app_state.budget_usd` is not touched either: that is only the default copied into each
|
|
43
|
+
* new period, so editing it here would silently change every month to come.
|
|
44
|
+
*/
|
|
45
|
+
export declare function setBudget(pool: Pool, options: SetBudgetOptions): Promise<SetBudgetResult>;
|
|
46
|
+
export interface SetBudgetActionOptions {
|
|
47
|
+
pool: Pool;
|
|
48
|
+
requireSession: RequireSession;
|
|
49
|
+
}
|
|
50
|
+
export type SetBudgetAction = (options: SetBudgetOptions) => Promise<SetBudgetResult>;
|
|
51
|
+
/**
|
|
52
|
+
* The admin action, guarded like `resetSecondFactor` and taking its actor from the guarded
|
|
53
|
+
* session rather than from whatever the form said.
|
|
54
|
+
*/
|
|
55
|
+
export declare function createSetBudgetAction(options: SetBudgetActionOptions): SetBudgetAction;
|
package/dist/budget.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ADMIN_ROLE } from "@hyperfixation/auth";
|
|
2
|
+
/** The audit row's `action`, alongside `app.pause` and `app.resume`. */
|
|
3
|
+
export const BUDGET_SET_OPERATION = "app.budget_set";
|
|
4
|
+
/** One line per change; the next gate's refusal will be asked about. */
|
|
5
|
+
export const BUDGET_SET_MARKER = "hf-admin: budget set";
|
|
6
|
+
/**
|
|
7
|
+
* The same row the gate locks, in the same order — this takes only that one lock, so it cannot
|
|
8
|
+
* deadlock against a gate holding `hf_run` first.
|
|
9
|
+
*/
|
|
10
|
+
const UPDATE_STATEMENT = "WITH before AS (SELECT period, budget_usd FROM hf_budget_period WHERE period = $1 FOR UPDATE) " +
|
|
11
|
+
"UPDATE hf_budget_period b SET budget_usd = $2::numeric FROM before " +
|
|
12
|
+
"WHERE b.period = before.period " +
|
|
13
|
+
"RETURNING before.budget_usd::text AS previous, b.budget_usd::text AS budget, " +
|
|
14
|
+
"b.spent_usd::text AS spent";
|
|
15
|
+
const AUDIT_STATEMENT = "INSERT INTO hf_audit (actor_id, action, target_type, target_id, meta) " +
|
|
16
|
+
`VALUES ($1, '${BUDGET_SET_OPERATION}', 'hf_budget_period', $2, $3::jsonb)`;
|
|
17
|
+
/** A budget that is not a finite, non-negative number. Refused before any lock is taken. */
|
|
18
|
+
export class InvalidBudget extends Error {
|
|
19
|
+
period;
|
|
20
|
+
budgetUsd;
|
|
21
|
+
constructor(period, budgetUsd) {
|
|
22
|
+
super(`InvalidBudget: ${JSON.stringify(budgetUsd)} is not a budget for ${period}; ` +
|
|
23
|
+
"it must be a finite, non-negative number of dollars");
|
|
24
|
+
this.name = "InvalidBudget";
|
|
25
|
+
this.period = period;
|
|
26
|
+
this.budgetUsd = budgetUsd;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** No row for that period yet. The first gate of a month creates it; nothing else does. */
|
|
30
|
+
export class UnknownBudgetPeriod extends Error {
|
|
31
|
+
period;
|
|
32
|
+
constructor(period) {
|
|
33
|
+
super(`UnknownBudgetPeriod: hf_budget_period has no row for ${period}; the first gate of a ` +
|
|
34
|
+
"period creates it from hf_app_state.budget_usd");
|
|
35
|
+
this.name = "UnknownBudgetPeriod";
|
|
36
|
+
this.period = period;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Sets one period's ceiling. The next gate reads it: nothing caches the budget, so the change
|
|
41
|
+
* lands on the next `llm.run` and not on any call already past its gate.
|
|
42
|
+
*
|
|
43
|
+
* A budget **below** what the period has already spent is allowed. It is not a correction but
|
|
44
|
+
* the kill-lever: the next gate compares `spent + reserved + estimate > budget` and refuses
|
|
45
|
+
* every further call for the period, which is what an admin watching a runaway month wants.
|
|
46
|
+
* `spent_usd` is never touched — the money is spent either way.
|
|
47
|
+
*
|
|
48
|
+
* `hf_app_state.budget_usd` is not touched either: that is only the default copied into each
|
|
49
|
+
* new period, so editing it here would silently change every month to come.
|
|
50
|
+
*/
|
|
51
|
+
export async function setBudget(pool, options) {
|
|
52
|
+
if (!Number.isFinite(options.budgetUsd) || options.budgetUsd < 0) {
|
|
53
|
+
throw new InvalidBudget(options.period, options.budgetUsd);
|
|
54
|
+
}
|
|
55
|
+
const client = await pool.connect();
|
|
56
|
+
try {
|
|
57
|
+
await client.query("BEGIN");
|
|
58
|
+
const updated = await client.query(UPDATE_STATEMENT, [options.period, options.budgetUsd]);
|
|
59
|
+
const row = updated.rows[0];
|
|
60
|
+
if (row === undefined)
|
|
61
|
+
throw new UnknownBudgetPeriod(options.period);
|
|
62
|
+
await client.query(AUDIT_STATEMENT, [
|
|
63
|
+
options.actorId ?? null,
|
|
64
|
+
options.period,
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
previousBudgetUsd: row.previous,
|
|
67
|
+
budgetUsd: row.budget,
|
|
68
|
+
spentUsd: row.spent,
|
|
69
|
+
reason: options.reason ?? null,
|
|
70
|
+
}),
|
|
71
|
+
]);
|
|
72
|
+
await client.query("COMMIT");
|
|
73
|
+
const result = {
|
|
74
|
+
period: options.period,
|
|
75
|
+
budgetUsd: row.budget,
|
|
76
|
+
previousBudgetUsd: row.previous,
|
|
77
|
+
spentUsd: row.spent,
|
|
78
|
+
};
|
|
79
|
+
console.info(BUDGET_SET_MARKER, JSON.stringify(result));
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
await client.query("ROLLBACK");
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
client.release();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The admin action, guarded like `resetSecondFactor` and taking its actor from the guarded
|
|
92
|
+
* session rather than from whatever the form said.
|
|
93
|
+
*/
|
|
94
|
+
export function createSetBudgetAction(options) {
|
|
95
|
+
return async (set) => {
|
|
96
|
+
const session = await options.requireSession({ factor: "passkey", role: ADMIN_ROLE });
|
|
97
|
+
return setBudget(options.pool, { ...set, actorId: session.user.id });
|
|
98
|
+
};
|
|
99
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { resourceFromTable, UnknownAdminField, type AdminActionDescriptor, type AdminField, type AdminFieldKind, type AdminResource, type ResourceFromTableOptions, } from "./resource.js";
|
|
2
|
+
export { usersResource, ADMIN_USERS_RESOURCE, RESET_SECOND_FACTOR_ACTION, } from "./users.js";
|
|
3
|
+
export { approvalsResource, budgetPeriodsResource, runsResource, ADMIN_APPROVALS_RESOURCE, ADMIN_BUDGET_PERIODS_RESOURCE, ADMIN_RUNS_RESOURCE, SET_BUDGET_ACTION, } from "./machinery.js";
|
|
4
|
+
export { createSetBudgetAction, setBudget, InvalidBudget, UnknownBudgetPeriod, BUDGET_SET_MARKER, BUDGET_SET_OPERATION, type SetBudgetAction, type SetBudgetActionOptions, type SetBudgetOptions, type SetBudgetResult, } from "./budget.js";
|
|
5
|
+
export { createAdminRouter, ADMIN_BASE_PATH, type AdminActions, type AdminRoute, type AdminRouter, type AdminRouterOptions, } from "./router.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { resourceFromTable, UnknownAdminField, } from "./resource.js";
|
|
2
|
+
export { usersResource, ADMIN_USERS_RESOURCE, RESET_SECOND_FACTOR_ACTION, } from "./users.js";
|
|
3
|
+
export { approvalsResource, budgetPeriodsResource, runsResource, ADMIN_APPROVALS_RESOURCE, ADMIN_BUDGET_PERIODS_RESOURCE, ADMIN_RUNS_RESOURCE, SET_BUDGET_ACTION, } from "./machinery.js";
|
|
4
|
+
export { createSetBudgetAction, setBudget, InvalidBudget, UnknownBudgetPeriod, BUDGET_SET_MARKER, BUDGET_SET_OPERATION, } from "./budget.js";
|
|
5
|
+
export { createAdminRouter, ADMIN_BASE_PATH, } from "./router.js";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type AdminResource } from "./resource.js";
|
|
2
|
+
export declare const ADMIN_APPROVALS_RESOURCE = "approvals";
|
|
3
|
+
export declare const ADMIN_RUNS_RESOURCE = "runs";
|
|
4
|
+
export declare const ADMIN_BUDGET_PERIODS_RESOURCE = "budget-periods";
|
|
5
|
+
/** The action's name on the resource; `AdminRouter.actions.setBudget` runs it. */
|
|
6
|
+
export declare const SET_BUDGET_ACTION = "set-budget";
|
|
7
|
+
/**
|
|
8
|
+
* Read-only: a decision is the workflow's to make through `decide()`, which fences it against
|
|
9
|
+
* the run. An admin who could edit `hf_approval` directly would be deciding behind the fence.
|
|
10
|
+
*/
|
|
11
|
+
export declare const approvalsResource: AdminResource;
|
|
12
|
+
/** Read-only for the same reason: `current_workflow_id` is the fencing token, not a field. */
|
|
13
|
+
export declare const runsResource: AdminResource;
|
|
14
|
+
/**
|
|
15
|
+
* The one machinery table an admin may write, and only `budget_usd` through the action.
|
|
16
|
+
* `spent_usd` is the ledger's own running total — editing it would make the gate lie.
|
|
17
|
+
*/
|
|
18
|
+
export declare const budgetPeriodsResource: AdminResource;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { hfApproval, hfBudgetPeriod, hfRun } from "@hyperfixation/db";
|
|
2
|
+
import { resourceFromTable } from "./resource.js";
|
|
3
|
+
export const ADMIN_APPROVALS_RESOURCE = "approvals";
|
|
4
|
+
export const ADMIN_RUNS_RESOURCE = "runs";
|
|
5
|
+
export const ADMIN_BUDGET_PERIODS_RESOURCE = "budget-periods";
|
|
6
|
+
/** The action's name on the resource; `AdminRouter.actions.setBudget` runs it. */
|
|
7
|
+
export const SET_BUDGET_ACTION = "set-budget";
|
|
8
|
+
/**
|
|
9
|
+
* Read-only: a decision is the workflow's to make through `decide()`, which fences it against
|
|
10
|
+
* the run. An admin who could edit `hf_approval` directly would be deciding behind the fence.
|
|
11
|
+
*/
|
|
12
|
+
export const approvalsResource = resourceFromTable(hfApproval, {
|
|
13
|
+
name: ADMIN_APPROVALS_RESOURCE,
|
|
14
|
+
list: ["id", "type", "status", "recordType", "recordId", "assigneeId", "createdAt"],
|
|
15
|
+
});
|
|
16
|
+
/** Read-only for the same reason: `current_workflow_id` is the fencing token, not a field. */
|
|
17
|
+
export const runsResource = resourceFromTable(hfRun, {
|
|
18
|
+
name: ADMIN_RUNS_RESOURCE,
|
|
19
|
+
list: ["runId", "flow", "status", "attempt", "recordType", "recordId", "startedAt"],
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* The one machinery table an admin may write, and only `budget_usd` through the action.
|
|
23
|
+
* `spent_usd` is the ledger's own running total — editing it would make the gate lie.
|
|
24
|
+
*/
|
|
25
|
+
export const budgetPeriodsResource = resourceFromTable(hfBudgetPeriod, {
|
|
26
|
+
name: ADMIN_BUDGET_PERIODS_RESOURCE,
|
|
27
|
+
list: ["period", "budgetUsd", "spentUsd"],
|
|
28
|
+
actions: [{ name: SET_BUDGET_ACTION, label: "Set budget", scope: "row" }],
|
|
29
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type Table } from "drizzle-orm";
|
|
2
|
+
/**
|
|
3
|
+
* What a UI renders a value as. Deliberately coarse: an admin list shows text, a number, a
|
|
4
|
+
* checkbox, a timestamp, or an opaque blob, and nothing here needs to know that a column is
|
|
5
|
+
* `varchar(64)` rather than `text`.
|
|
6
|
+
*/
|
|
7
|
+
export type AdminFieldKind = "string" | "number" | "boolean" | "date" | "json";
|
|
8
|
+
export interface AdminField {
|
|
9
|
+
/** The Drizzle property name. What a caller names a field by. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** The SQL column, off the metadata, so nothing downstream re-derives the snake_case form. */
|
|
12
|
+
column: string;
|
|
13
|
+
label: string;
|
|
14
|
+
kind: AdminFieldKind;
|
|
15
|
+
nullable: boolean;
|
|
16
|
+
hasDefault: boolean;
|
|
17
|
+
primaryKey: boolean;
|
|
18
|
+
unique: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface AdminActionDescriptor {
|
|
21
|
+
name: string;
|
|
22
|
+
label: string;
|
|
23
|
+
/** Phase 1 has one shape: an action against one row. */
|
|
24
|
+
scope: "row";
|
|
25
|
+
}
|
|
26
|
+
export interface AdminResource {
|
|
27
|
+
name: string;
|
|
28
|
+
/** The SQL table this resource is over. */
|
|
29
|
+
table: string;
|
|
30
|
+
primaryKey: readonly string[];
|
|
31
|
+
fields: readonly AdminField[];
|
|
32
|
+
/** Field names, in order, for the list view. A subset of `fields`. */
|
|
33
|
+
list: readonly string[];
|
|
34
|
+
/** Field names for one row. Every field unless the caller narrows it. */
|
|
35
|
+
view: readonly string[];
|
|
36
|
+
actions: readonly AdminActionDescriptor[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Thrown at construction, not at render: `list` and `view` are the one hand-written part of a
|
|
40
|
+
* resource and therefore the one part a column rename can rot.
|
|
41
|
+
*/
|
|
42
|
+
export declare class UnknownAdminField extends Error {
|
|
43
|
+
readonly resource: string;
|
|
44
|
+
readonly field: string;
|
|
45
|
+
readonly known: readonly string[];
|
|
46
|
+
constructor(resource: string, field: string, known: readonly string[]);
|
|
47
|
+
}
|
|
48
|
+
export interface ResourceFromTableOptions {
|
|
49
|
+
name: string;
|
|
50
|
+
list: readonly string[];
|
|
51
|
+
/** Defaults to every field the table has. */
|
|
52
|
+
view?: readonly string[];
|
|
53
|
+
actions?: readonly AdminActionDescriptor[];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A resource read off the Drizzle table rather than hand-typed beside it, so the fields, their
|
|
57
|
+
* SQL columns, their nullability, defaults, primary key and uniqueness are whatever the schema
|
|
58
|
+
* currently declares. What stays declared is editorial: the resource's name, which fields the
|
|
59
|
+
* list shows and in what order, and which actions it offers — none of which the metadata knows.
|
|
60
|
+
* Those declarations are checked against the metadata here.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resourceFromTable(table: Table, options: ResourceFromTableOptions): AdminResource;
|
package/dist/resource.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { getTableColumns, getTableName } from "drizzle-orm";
|
|
2
|
+
/**
|
|
3
|
+
* Thrown at construction, not at render: `list` and `view` are the one hand-written part of a
|
|
4
|
+
* resource and therefore the one part a column rename can rot.
|
|
5
|
+
*/
|
|
6
|
+
export class UnknownAdminField extends Error {
|
|
7
|
+
resource;
|
|
8
|
+
field;
|
|
9
|
+
known;
|
|
10
|
+
constructor(resource, field, known) {
|
|
11
|
+
super(`UnknownAdminField: the ${resource} resource names ${JSON.stringify(field)}, ` +
|
|
12
|
+
`which its table does not have; it has ${known.join(", ")}`);
|
|
13
|
+
this.name = "UnknownAdminField";
|
|
14
|
+
this.resource = resource;
|
|
15
|
+
this.field = field;
|
|
16
|
+
this.known = known;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const KINDS = {
|
|
20
|
+
string: "string",
|
|
21
|
+
number: "number",
|
|
22
|
+
bigint: "number",
|
|
23
|
+
boolean: "boolean",
|
|
24
|
+
date: "date",
|
|
25
|
+
json: "json",
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* An unmapped Drizzle data type degrades to text rather than refusing the whole resource:
|
|
29
|
+
* everything Postgres returns can be shown as text, and a column type nobody taught this
|
|
30
|
+
* function about is not a reason for the admin to stop existing.
|
|
31
|
+
*/
|
|
32
|
+
function kindOf(dataType) {
|
|
33
|
+
return KINDS[dataType] ?? "string";
|
|
34
|
+
}
|
|
35
|
+
/** `banReason` → "Ban reason". Generated, so a new column arrives already labelled. */
|
|
36
|
+
function labelOf(name) {
|
|
37
|
+
const words = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
38
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A resource read off the Drizzle table rather than hand-typed beside it, so the fields, their
|
|
42
|
+
* SQL columns, their nullability, defaults, primary key and uniqueness are whatever the schema
|
|
43
|
+
* currently declares. What stays declared is editorial: the resource's name, which fields the
|
|
44
|
+
* list shows and in what order, and which actions it offers — none of which the metadata knows.
|
|
45
|
+
* Those declarations are checked against the metadata here.
|
|
46
|
+
*/
|
|
47
|
+
export function resourceFromTable(table, options) {
|
|
48
|
+
const fields = Object.entries(getTableColumns(table)).map(([name, column]) => ({
|
|
49
|
+
name,
|
|
50
|
+
column: column.name,
|
|
51
|
+
label: labelOf(name),
|
|
52
|
+
kind: kindOf(column.dataType),
|
|
53
|
+
nullable: !column.notNull,
|
|
54
|
+
hasDefault: column.hasDefault,
|
|
55
|
+
primaryKey: column.primary,
|
|
56
|
+
unique: column.isUnique ?? false,
|
|
57
|
+
}));
|
|
58
|
+
const known = fields.map((field) => field.name);
|
|
59
|
+
const checked = (names) => {
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
if (!known.includes(name))
|
|
62
|
+
throw new UnknownAdminField(options.name, name, known);
|
|
63
|
+
}
|
|
64
|
+
return names;
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
name: options.name,
|
|
68
|
+
table: getTableName(table),
|
|
69
|
+
primaryKey: fields.filter((field) => field.primaryKey).map((field) => field.name),
|
|
70
|
+
fields,
|
|
71
|
+
list: checked(options.list),
|
|
72
|
+
view: options.view === undefined ? known : checked(options.view),
|
|
73
|
+
actions: options.actions ?? [],
|
|
74
|
+
};
|
|
75
|
+
}
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type RequireSession, type ResetSecondFactorAction } from "@hyperfixation/auth";
|
|
2
|
+
import { type Registry } from "@hyperfixation/core";
|
|
3
|
+
import type { Pool } from "pg";
|
|
4
|
+
import { type SetBudgetAction } from "./budget.js";
|
|
5
|
+
import type { AdminResource } from "./resource.js";
|
|
6
|
+
/** Where the template mounts the admin. Only the default; `route()` takes what it is given. */
|
|
7
|
+
export declare const ADMIN_BASE_PATH = "/admin";
|
|
8
|
+
export type AdminRoute = {
|
|
9
|
+
kind: "index";
|
|
10
|
+
} | {
|
|
11
|
+
kind: "list";
|
|
12
|
+
resource: AdminResource;
|
|
13
|
+
} | {
|
|
14
|
+
kind: "detail";
|
|
15
|
+
resource: AdminResource;
|
|
16
|
+
id: string;
|
|
17
|
+
};
|
|
18
|
+
/** The admin's server actions. Each one carries its own guard. */
|
|
19
|
+
export interface AdminActions {
|
|
20
|
+
resetSecondFactor: ResetSecondFactorAction;
|
|
21
|
+
setBudget: SetBudgetAction;
|
|
22
|
+
}
|
|
23
|
+
export interface AdminRouterOptions {
|
|
24
|
+
/** The app's own pool. The admin builds none and runs on the web request path. */
|
|
25
|
+
pool: Pool;
|
|
26
|
+
requireSession: RequireSession;
|
|
27
|
+
/** The app's own resources, beyond the built-in ones. */
|
|
28
|
+
resources?: readonly AdminResource[];
|
|
29
|
+
}
|
|
30
|
+
export interface AdminRouter {
|
|
31
|
+
readonly resources: Registry<AdminResource>;
|
|
32
|
+
readonly actions: AdminActions;
|
|
33
|
+
/**
|
|
34
|
+
* The catch-all's body: the segments below the mount point, guarded and resolved. `undefined`
|
|
35
|
+
* is a path the admin does not serve — the host renders its own not-found for it, the same
|
|
36
|
+
* one a refusal produces.
|
|
37
|
+
*/
|
|
38
|
+
route(path?: string | readonly string[]): Promise<AdminRoute | undefined>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The admin, as one guarded entry point the app's `(admin)/admin/[[...path]]` route calls.
|
|
42
|
+
*
|
|
43
|
+
* The guard runs before the path is resolved, and it runs on every route including the index.
|
|
44
|
+
* Resolving first would let `/admin/widgets` and `/admin/users` answer a stranger differently,
|
|
45
|
+
* and the difference between those two answers is a map of the admin area — the thing the 404
|
|
46
|
+
* exists to withhold. `requireSession({ factor: 'passkey', role: 'admin' })` is stated here
|
|
47
|
+
* rather than left to the route's own `pathname`, so a host that mounts the admin somewhere
|
|
48
|
+
* else still gets the admin bar.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createAdminRouter(options: AdminRouterOptions): AdminRouter;
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ADMIN_ROLE, createResetSecondFactorAction, } from "@hyperfixation/auth";
|
|
2
|
+
import { createRegistry } from "@hyperfixation/core";
|
|
3
|
+
import { createSetBudgetAction } from "./budget.js";
|
|
4
|
+
import { approvalsResource, budgetPeriodsResource, runsResource } from "./machinery.js";
|
|
5
|
+
import { usersResource } from "./users.js";
|
|
6
|
+
/** Where the template mounts the admin. Only the default; `route()` takes what it is given. */
|
|
7
|
+
export const ADMIN_BASE_PATH = "/admin";
|
|
8
|
+
/** The framework's own tables. An app registers none of these; they come with the admin. */
|
|
9
|
+
const BUILT_IN_RESOURCES = [
|
|
10
|
+
usersResource,
|
|
11
|
+
approvalsResource,
|
|
12
|
+
runsResource,
|
|
13
|
+
budgetPeriodsResource,
|
|
14
|
+
];
|
|
15
|
+
function segmentsOf(path) {
|
|
16
|
+
// A catch-all already hands back the segments below the mount, so an array is taken as-is —
|
|
17
|
+
// a resource named `admin` stays reachable. Only a whole pathname has the mount on the front.
|
|
18
|
+
if (typeof path !== "string")
|
|
19
|
+
return (path ?? []).filter((segment) => segment.length > 0);
|
|
20
|
+
const segments = path.split("/").filter((segment) => segment.length > 0);
|
|
21
|
+
return segments[0] === ADMIN_BASE_PATH.slice(1) ? segments.slice(1) : segments;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The admin, as one guarded entry point the app's `(admin)/admin/[[...path]]` route calls.
|
|
25
|
+
*
|
|
26
|
+
* The guard runs before the path is resolved, and it runs on every route including the index.
|
|
27
|
+
* Resolving first would let `/admin/widgets` and `/admin/users` answer a stranger differently,
|
|
28
|
+
* and the difference between those two answers is a map of the admin area — the thing the 404
|
|
29
|
+
* exists to withhold. `requireSession({ factor: 'passkey', role: 'admin' })` is stated here
|
|
30
|
+
* rather than left to the route's own `pathname`, so a host that mounts the admin somewhere
|
|
31
|
+
* else still gets the admin bar.
|
|
32
|
+
*/
|
|
33
|
+
export function createAdminRouter(options) {
|
|
34
|
+
const resources = createRegistry("admin resource");
|
|
35
|
+
for (const resource of BUILT_IN_RESOURCES)
|
|
36
|
+
resources.register(resource);
|
|
37
|
+
for (const resource of options.resources ?? [])
|
|
38
|
+
resources.register(resource);
|
|
39
|
+
return {
|
|
40
|
+
resources,
|
|
41
|
+
actions: {
|
|
42
|
+
resetSecondFactor: createResetSecondFactorAction({
|
|
43
|
+
pool: options.pool,
|
|
44
|
+
requireSession: options.requireSession,
|
|
45
|
+
}),
|
|
46
|
+
setBudget: createSetBudgetAction({
|
|
47
|
+
pool: options.pool,
|
|
48
|
+
requireSession: options.requireSession,
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
async route(path) {
|
|
52
|
+
const segments = segmentsOf(path);
|
|
53
|
+
await options.requireSession({
|
|
54
|
+
factor: "passkey",
|
|
55
|
+
role: ADMIN_ROLE,
|
|
56
|
+
pathname: [ADMIN_BASE_PATH, ...segments].join("/"),
|
|
57
|
+
});
|
|
58
|
+
if (segments.length === 0)
|
|
59
|
+
return { kind: "index" };
|
|
60
|
+
if (segments.length > 2)
|
|
61
|
+
return undefined;
|
|
62
|
+
const resource = resources.get(segments[0]);
|
|
63
|
+
if (resource === undefined)
|
|
64
|
+
return undefined;
|
|
65
|
+
const id = segments[1];
|
|
66
|
+
return id === undefined ? { kind: "list", resource } : { kind: "detail", resource, id };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
package/dist/users.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type AdminResource } from "./resource.js";
|
|
2
|
+
export declare const ADMIN_USERS_RESOURCE = "users";
|
|
3
|
+
/** The action's name on the resource; `AdminRouter.actions.resetSecondFactor` runs it. */
|
|
4
|
+
export declare const RESET_SECOND_FACTOR_ACTION = "reset-second-factor";
|
|
5
|
+
/**
|
|
6
|
+
* Phase 1's one resource. Every field comes off `hf_user`'s Drizzle metadata; the list is the
|
|
7
|
+
* five columns an admin scans for — who, what they are called, what they may do, whether they
|
|
8
|
+
* are shut out, and when they arrived.
|
|
9
|
+
*/
|
|
10
|
+
export declare const usersResource: AdminResource;
|
package/dist/users.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { hfUser } from "@hyperfixation/db";
|
|
2
|
+
import { resourceFromTable } from "./resource.js";
|
|
3
|
+
export const ADMIN_USERS_RESOURCE = "users";
|
|
4
|
+
/** The action's name on the resource; `AdminRouter.actions.resetSecondFactor` runs it. */
|
|
5
|
+
export const RESET_SECOND_FACTOR_ACTION = "reset-second-factor";
|
|
6
|
+
/**
|
|
7
|
+
* Phase 1's one resource. Every field comes off `hf_user`'s Drizzle metadata; the list is the
|
|
8
|
+
* five columns an admin scans for — who, what they are called, what they may do, whether they
|
|
9
|
+
* are shut out, and when they arrived.
|
|
10
|
+
*/
|
|
11
|
+
export const usersResource = resourceFromTable(hfUser, {
|
|
12
|
+
name: ADMIN_USERS_RESOURCE,
|
|
13
|
+
list: ["email", "name", "role", "banned", "createdAt"],
|
|
14
|
+
actions: [
|
|
15
|
+
{ name: RESET_SECOND_FACTOR_ACTION, label: "Reset second factor", scope: "row" },
|
|
16
|
+
],
|
|
17
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hyperfixation/admin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Admin router and resource definitions generated from Drizzle metadata",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/grahamlutz/hyperfixation-core.git",
|
|
9
|
+
"directory": "packages/admin"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"!dist/**/*.test.*",
|
|
25
|
+
"!dist/test-support/**"
|
|
26
|
+
],
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@hyperfixation/ai": "0.1.0",
|
|
29
|
+
"@hyperfixation/eslint-config": "0.1.0",
|
|
30
|
+
"@hyperfixation/testing": "0.1.0",
|
|
31
|
+
"@microsoft/api-extractor": "^7.59.1",
|
|
32
|
+
"@types/pg": "^8.23.1",
|
|
33
|
+
"eslint": "^10.10.0"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@hyperfixation/auth": "0.1.0",
|
|
37
|
+
"@hyperfixation/core": "0.1.0",
|
|
38
|
+
"@hyperfixation/db": "0.1.0",
|
|
39
|
+
"drizzle-orm": "^0.45.2",
|
|
40
|
+
"pg": "^8.23.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -p tsconfig.json",
|
|
44
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
45
|
+
"lint": "eslint src",
|
|
46
|
+
"api-extractor": "api-extractor run",
|
|
47
|
+
"api-extractor:update": "api-extractor run --local",
|
|
48
|
+
"test": "vitest run --passWithNoTests"
|
|
49
|
+
}
|
|
50
|
+
}
|