feat(roles): roles remember the jobs they follow (re-appliable), every role edit is signed
Closes the permissions-matrix loose ends (venue-modules.md §Permissions matrix): - `role_jobs` (migration 0029): a role stores the manifest jobs it was composed from (chips on at save + any bundle fully present). `jobById` / `jobsBehind` in @parking/shared surface a followed job whose bundle grew past the role in a later release; the roles list shows a "behind <job>" badge with a one-click "Update to job" (the union, nothing removed) and the editor lints it. Never a runtime union: the grid stays the explicit enforcement layer and an update never widens a role without a click. - Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value = name + sorted permissions + jobs, operator); a no-op resave signs nothing. roleRoutes now takes the ledger. - booth-supervisor already carries subscription:*; the stale open note is closed. Tests: routes/roles.test.ts. Wiki: venue-modules status, local-jwt-auth, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
-- Roles remember the manifest JOBS they were composed from (venue-modules.md §"Permissions
|
||||
-- matrix", move 2) so a role built from a job chip can be flagged and re-applied when a
|
||||
-- later release grows that job's bundle. The permission grid stays the enforcement layer.
|
||||
CREATE TABLE `role_jobs` (
|
||||
`role_id` text NOT NULL,
|
||||
`job_id` text NOT NULL,
|
||||
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `role_jobs_role_id_job_id_unique` ON `role_jobs` (`role_id`,`job_id`);
|
||||
@@ -204,6 +204,13 @@
|
||||
"when": 1788605000000,
|
||||
"tag": "0028_carwash_config",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "6",
|
||||
"when": 1788690000000,
|
||||
"tag": "0029_role_jobs",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -53,6 +53,24 @@ export const rolePermissions = sqliteTable(
|
||||
}),
|
||||
);
|
||||
|
||||
/** The JOBS a role follows (venue-modules.md §"Permissions matrix", move 2): the
|
||||
* manifest job presets the admin composed it from. Remembered so a later release that
|
||||
* grows a job's bundle can be surfaced ("this role is behind the Wash operator job")
|
||||
* and re-applied with one click — never expanded silently at runtime: what a role may
|
||||
* do is always the explicit `role_permissions` grid. */
|
||||
export const roleJobs = sqliteTable(
|
||||
"role_jobs",
|
||||
{
|
||||
roleId: text("role_id")
|
||||
.notNull()
|
||||
.references(() => roles.id),
|
||||
jobId: text("job_id").notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
uniq: unique().on(t.roleId, t.jobId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
username: text("username").notNull().unique(),
|
||||
|
||||
@@ -1937,6 +1937,31 @@ export function tillOfEvent(type: LedgerEventType, payload: { till?: TillId } |
|
||||
return m?.till ?? BOOTH_TILL;
|
||||
}
|
||||
|
||||
/** A job preset by id, with the module that declares it (null = no such job — e.g. a
|
||||
* job remembered by a role whose module was removed from the registry). */
|
||||
export function jobById(id: string): { module: ModuleId; job: JobPreset } | null {
|
||||
for (const m of MODULES) for (const job of m.jobs) if (job.id === id) return { module: m.id, job };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The jobs a role FOLLOWS whose bundle has grown past what the role holds: the role
|
||||
* was built from the chip, a later release added a permission to the job, and the
|
||||
* role fell behind. The admin re-applies with one click (or drops the job); the grid
|
||||
* is never expanded silently. Jobs no longer in the registry are ignored. */
|
||||
export function jobsBehind(
|
||||
jobs: readonly string[],
|
||||
has: (p: Permission) => boolean,
|
||||
): { job: string; missing: Permission[] }[] {
|
||||
const out: { job: string; missing: Permission[] }[] = [];
|
||||
for (const id of jobs) {
|
||||
const found = jobById(id);
|
||||
if (!found) continue;
|
||||
const missing = found.job.permissions.filter((p) => !has(p));
|
||||
if (missing.length > 0) out.push({ job: id, missing });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function tillsWorkableBy(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
|
||||
return tillsFor(effective, has, "shift");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user