Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0788d15c0 | ||
|
|
7704867e05 | ||
|
|
04a4ab33dd |
@@ -0,0 +1,64 @@
|
|||||||
|
# 2026.08.23.03
|
||||||
|
|
||||||
|
Roles become a table, and the localization infrastructure lands. Both are groundwork — nothing in the UI
|
||||||
|
changes and nothing reads the new table yet.
|
||||||
|
|
||||||
|
**SCHEMA CHANGE.** `bun db:push` is a step, and `postdb:push` now seeds the new table automatically. The
|
||||||
|
`roles` table is new. Additive only: no constraint is added to an existing column, so this cannot fail on
|
||||||
|
existing data.
|
||||||
|
|
||||||
|
**No dependency change.** `bun.lock` gained two lines and they are the local `i18n` workspace package being
|
||||||
|
declared as a dependency of `officerdev` — a workspace path, not a registry fetch. **Do not run
|
||||||
|
`bun install`.**
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **`roles` is a table**, seeded with the four the platform already had, each marked `isSystem` because the
|
||||||
|
code names it: `rolePermitsDevTools()` tests for `Developer`, bootstrap writes `Super Admin`, `users.role`
|
||||||
|
defaults to `Member`. Nothing reads the table yet — `USER_ROLES` is still the type and `authorize.ts` is
|
||||||
|
untouched. **There is deliberately no foreign key on `users.role`**; that change cannot go through
|
||||||
|
`db:push` and is a later release.
|
||||||
|
- **The seed runs from `postdb:push`**, so a host gets the four rows on its next schema push with no extra
|
||||||
|
step here. It never overwrites an existing row.
|
||||||
|
- **Localization phase 0** — plural selection through `Intl.PluralRules`, a `useFormat()` hook for dates and
|
||||||
|
numbers, three namespaced catalogs deep-merged at the bridge, `dir` on `<html>`, and key-parity tests that
|
||||||
|
fail the build on a missing key. The language picker now offers only the two catalogs that exist rather
|
||||||
|
than twenty codes, eighteen of which silently rendered English. Stored bare codes widen on read
|
||||||
|
(`en` → `en-GB`), with no database migration.
|
||||||
|
- **SendOff's folder name is served rather than derived** — `/sendoff/targets` carries `displayName` and
|
||||||
|
`myFolder` from the same helper the writer uses, so a picker cannot name a folder the writer will not
|
||||||
|
create.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.03`
|
||||||
|
2. **`bun db:push`** — and per the procedure, **verify by query, not by exit code.** Check that `roles`
|
||||||
|
exists and holds four rows on that host. `db:push` can fail and still exit 0.
|
||||||
|
3. `bun run plugins:update`
|
||||||
|
4. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
|
||||||
|
the report.
|
||||||
|
|
||||||
|
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||||
|
|
||||||
|
## The check that matters this release
|
||||||
|
|
||||||
|
```sql
|
||||||
|
select count(*) from roles; -- expect 4
|
||||||
|
```
|
||||||
|
|
||||||
|
Four rows named `Super Admin`, `Admin`, `Developer`, `Member`. If a host shows the table but zero rows, the
|
||||||
|
`postdb:push` hook did not run — report it as a failure for that host rather than as a success, and say so
|
||||||
|
per host in the report.
|
||||||
|
|
||||||
|
No direct SQL is needed and none is authorised for this release.
|
||||||
|
|
||||||
|
## Hosts to expect trouble from
|
||||||
|
|
||||||
|
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
|
||||||
|
- **`production-pertento-bridge`** — came back last release after two behind. Nothing special expected.
|
||||||
|
|
||||||
|
## `officer-dev` — skip it
|
||||||
|
|
||||||
|
The rollout runs from here. It already holds the release and the four rows, verified locally.
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "officer",
|
"name": "officer",
|
||||||
"version": "2026.08.23.02",
|
"version": "2026.08.23.03",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
||||||
"db:gen": "cd src/databases/officer_db && bun run generate",
|
"db:gen": "cd src/databases/officer_db && bun run generate",
|
||||||
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
||||||
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts",
|
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts && bun run --env-file=.env scripts/seed-roles.ts",
|
||||||
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
||||||
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
||||||
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
||||||
|
|||||||
@@ -192,6 +192,51 @@ assemble at the end from memory is a report that will be wrong about the host th
|
|||||||
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
|
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
|
||||||
one that stopped in the middle and said nothing about the four hosts it never reached.
|
one that stopped in the middle and said nothing about the four hosts it never reached.
|
||||||
|
|
||||||
|
### Schema changes, and why `db:push` cannot be trusted
|
||||||
|
|
||||||
|
**`drizzle-kit push` can fail and still exit 0.** Verified on 2026-08-23 against a scratch database: a
|
||||||
|
change that violated a foreign key printed the Postgres error, created part of the change, left the rest
|
||||||
|
undone, and returned zero. `bun db:push` wraps it, so a plan step that reads `bun db:push` can report
|
||||||
|
success while the schema is half-applied.
|
||||||
|
|
||||||
|
So on any release whose plan says the schema changed:
|
||||||
|
|
||||||
|
**Never take the exit code as the answer. Check the database.** Query for the specific thing the plan
|
||||||
|
introduced — the table, the column — on that host, against that host's own database. `Changes applied` on
|
||||||
|
stdout is not evidence, and neither is exit 0. A report that says "schema ✓" without a query behind it is
|
||||||
|
a guess.
|
||||||
|
|
||||||
|
**Some changes cannot go through `db:push` at all, and the plan should say so.** The shape that fails is
|
||||||
|
create-then-constrain: a new table plus a foreign key onto it from an existing column. `push` creates the
|
||||||
|
table empty and adds the constraint in the same run, so every existing row violates it. There is no
|
||||||
|
ordering of `push` invocations that fixes this, because the seed has to happen between the two halves.
|
||||||
|
|
||||||
|
**When the plan says the schema needs direct SQL — last resort, and only when the plan says it.**
|
||||||
|
|
||||||
|
This is allowed and it is not a licence to improvise. The rules, in order:
|
||||||
|
|
||||||
|
1. **The Drizzle schema is the source of truth, always.** The SQL you run exists to make the database match
|
||||||
|
what the schema code already says. If you find yourself writing SQL the schema does not describe, stop —
|
||||||
|
that is a code change and it is not yours to make on five production hosts at 3am.
|
||||||
|
2. **The plan must contain the exact SQL.** Written by whoever cut the release, reviewed before the tag.
|
||||||
|
Do not compose it yourself from the diff, and do not adapt it per host: same statements, every host, or
|
||||||
|
the fleet has diverged in a way nothing will detect.
|
||||||
|
3. **Idempotent, or guarded.** `create table if not exists`, `insert … on conflict do nothing`,
|
||||||
|
`alter table … add constraint` only after checking `pg_constraint`. A deploy that is safe to run twice
|
||||||
|
is a deploy that can be retried, and being retryable is what stops a partial rollout becoming a
|
||||||
|
forensic exercise.
|
||||||
|
4. **Then run `bun db:push` anyway**, and expect it to be a no-op. That is the proof the two agree: if
|
||||||
|
`push` still wants to change something after your SQL, the SQL was wrong or incomplete, and that is a
|
||||||
|
finding for the report rather than something to push past.
|
||||||
|
5. **Verify per host, by query**, and put the result in the report per host. Not "applied on all five" —
|
||||||
|
the specific check, and its answer, for each.
|
||||||
|
6. **A host where the SQL fails is a failed host.** Report it, move to the next, and do not attempt a
|
||||||
|
repair you invented.
|
||||||
|
|
||||||
|
**One thing to refuse outright:** `--force`. It exists to answer prompts unattended, and the prompts it
|
||||||
|
answers are the destructive ones. On five hosts holding real data, a flag whose whole job is to say yes to
|
||||||
|
"this will truncate a table" is not a convenience. If a change needs `--force` to apply, it needs a human.
|
||||||
|
|
||||||
### Skip the host you are running on
|
### Skip the host you are running on
|
||||||
|
|
||||||
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
|
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Seed the `roles` table with the roles the platform's own code names.
|
||||||
|
*
|
||||||
|
* Runs from `postdb:push`, so every machine gets the rows on its next schema push rather than needing a
|
||||||
|
* per-server step in a deploy plan. Bun fires `pre`/`post` hooks for colon-named scripts — the same
|
||||||
|
* mechanism `backfill-session-models.ts` relies on.
|
||||||
|
*
|
||||||
|
* ── Why these four are `isSystem` ──
|
||||||
|
*
|
||||||
|
* Each is named by name somewhere in the code: `rolePermitsDevTools()` tests for `'Developer'`,
|
||||||
|
* `bootstrap.ts` writes `'Super Admin'`, `users.role` defaults to `'Member'`, and `'Admin'` is the one
|
||||||
|
* `rolePermitsDevTools` deliberately excludes. Renaming or deleting one of those is not a data change, it
|
||||||
|
* is a code change that has not happened — so the flag exists for the delete and rename paths to refuse on,
|
||||||
|
* rather than for the failure to turn up later as a missing database role or an account that cannot log in.
|
||||||
|
*
|
||||||
|
* ── What this deliberately does NOT do ──
|
||||||
|
*
|
||||||
|
* No foreign key from `users.role`. That change cannot go through `db:push`: push creates the table empty
|
||||||
|
* and adds the constraint in the same run, so every existing account violates it — verified against a
|
||||||
|
* scratch database on 2026-08-23, where push reported the violation, created the table, skipped the
|
||||||
|
* constraint, and exited 0. The FK is a separate release, once every host holds these rows.
|
||||||
|
*
|
||||||
|
* So today `users.role` is still a plain text column and nothing enforces that its value is a real role.
|
||||||
|
* That is the intended state of this phase: additive, unable to fail, and safe to run on five hosts
|
||||||
|
* unattended.
|
||||||
|
*
|
||||||
|
* Never overwrites. A description edited on one server must not be reverted by the next deploy.
|
||||||
|
*/
|
||||||
|
import { seedRole, waitForDatabase } from '../src/databases/officer_db/src/index';
|
||||||
|
|
||||||
|
const SEED: { name: string; description: string }[] = [
|
||||||
|
{ name: 'Super Admin', description: 'Full authority over the platform, and an OS account with sudo' },
|
||||||
|
{ name: 'Admin', description: 'Administers the platform. No operating-system privilege' },
|
||||||
|
{ name: 'Developer', description: 'A member who is also trusted with developer tooling on the machine' },
|
||||||
|
{ name: 'Member', description: 'The default. Reaches only what their role has been granted' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
await waitForDatabase();
|
||||||
|
let created = 0;
|
||||||
|
for (const role of SEED) {
|
||||||
|
if (await seedRole({ ...role, isSystem: true })) created += 1;
|
||||||
|
}
|
||||||
|
console.log(`[seed:roles] ${created} seeded, ${SEED.length - created} already present (of ${SEED.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never fatal. This runs from a `post` hook after a schema push, and a seed that cannot write must not make
|
||||||
|
// a successful push look like a failed one — the rows are additive and the next push tries again.
|
||||||
|
main()
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error('[seed:roles] could not seed:', err instanceof Error ? err.message : err);
|
||||||
|
})
|
||||||
|
.finally(() => process.exit(0));
|
||||||
@@ -41,6 +41,7 @@ export * from './anthropic-accounts';
|
|||||||
export * from './theme-favorites';
|
export * from './theme-favorites';
|
||||||
export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
|
export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
|
||||||
export * from './sendoff';
|
export * from './sendoff';
|
||||||
|
export * from './roles';
|
||||||
export * from './command-hq';
|
export * from './command-hq';
|
||||||
export * from './dashboards';
|
export * from './dashboards';
|
||||||
export * from './integrations';
|
export * from './integrations';
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { roles } from './schema';
|
||||||
|
export { listRoles, getRole, seedRole, type RoleRow } from './queries';
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { asc, eq, sql } from 'drizzle-orm';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { users } from '../auth/schema';
|
||||||
|
import { roles } from './schema';
|
||||||
|
|
||||||
|
export type RoleRow = {
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
isSystem: boolean;
|
||||||
|
provisioningScript: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every role, with how many accounts hold each.
|
||||||
|
*
|
||||||
|
* The count is here rather than in a second query because every caller needs it: a picker wants to show
|
||||||
|
* it, and the delete guard is defined in terms of it. One left join beats two round trips and a race
|
||||||
|
* between them.
|
||||||
|
*/
|
||||||
|
export async function listRoles(): Promise<(RoleRow & { userCount: number })[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
name: roles.name,
|
||||||
|
description: roles.description,
|
||||||
|
isSystem: roles.isSystem,
|
||||||
|
provisioningScript: roles.provisioningScript,
|
||||||
|
createdAt: roles.createdAt,
|
||||||
|
userCount: sql<number>`count(${users.id})::int`,
|
||||||
|
})
|
||||||
|
.from(roles)
|
||||||
|
.leftJoin(users, eq(users.role, roles.name))
|
||||||
|
.groupBy(roles.name, roles.description, roles.isSystem, roles.provisioningScript, roles.createdAt)
|
||||||
|
.orderBy(asc(roles.name));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRole(name: string): Promise<RoleRow | undefined> {
|
||||||
|
const [row] = await db.select().from(roles).where(eq(roles.name, name)).limit(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed a role if it is not there. Idempotent, and it does NOT overwrite an existing row.
|
||||||
|
*
|
||||||
|
* Called from `postdb:push` on every machine, so it runs again on every schema push for the rest of this
|
||||||
|
* platform's life. An upsert would mean a description edited on one server is silently reverted by the next
|
||||||
|
* deploy — so the seed establishes a row and never touches it again.
|
||||||
|
*/
|
||||||
|
export async function seedRole(row: {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
isSystem?: boolean;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const inserted = await db
|
||||||
|
.insert(roles)
|
||||||
|
.values({ name: row.name, description: row.description ?? null, isSystem: row.isSystem ?? false })
|
||||||
|
.onConflictDoNothing({ target: roles.name })
|
||||||
|
.returning({ name: roles.name });
|
||||||
|
return inserted.length > 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roles this server has, as data rather than a TypeScript union.
|
||||||
|
*
|
||||||
|
* ── Why the NAME is the primary key ──
|
||||||
|
*
|
||||||
|
* `users.role` and `role_permissions.role` both hold a role name today, and `authorize.ts` reads a name off
|
||||||
|
* the user to look up grants. Re-keying those onto a serial id would touch the authorization path — the one
|
||||||
|
* file standing between a Member and a shell — to buy nothing the name does not already give, because
|
||||||
|
* Postgres will cascade a rename for us. So both columns become foreign keys onto this one with
|
||||||
|
* `ON UPDATE CASCADE`: renaming a role moves every user and every grant with it, atomically, and no code
|
||||||
|
* changes.
|
||||||
|
*
|
||||||
|
* The cost is that a rename is a data migration rather than a label edit. That is honest — a role name
|
||||||
|
* appears in `rolePermitsDevTools`, in report text and in whatever a plugin has written down.
|
||||||
|
*
|
||||||
|
* ── What is NOT here ──
|
||||||
|
*
|
||||||
|
* **Ownership.** The owner is `OWNER_USER_ID`, an identity, not a role — see `super-admin.ts`. Before this
|
||||||
|
* table existed, "who owns this server" was answered by `users.role === 'Super Admin'`, which meant that
|
||||||
|
* making roles editable would have made "add a role" and "become the owner" the same feature. Several
|
||||||
|
* accounts may now hold `Super Admin`; exactly one account is the owner, and no role grants it.
|
||||||
|
*
|
||||||
|
* **Permissions.** They stay in `role_permissions`, keyed on the name. A role row is the role's identity and
|
||||||
|
* its provisioning, not its grants.
|
||||||
|
*/
|
||||||
|
export const roles = pgTable('roles', {
|
||||||
|
/** The name, and the key. Referenced by `users.role` and `role_permissions.role` with ON UPDATE CASCADE. */
|
||||||
|
name: text('name').primaryKey(),
|
||||||
|
/** One line, for a picker. Never load-bearing — nothing branches on it. */
|
||||||
|
description: text('description'),
|
||||||
|
/**
|
||||||
|
* A role the platform's own code names, so it cannot be deleted or renamed.
|
||||||
|
*
|
||||||
|
* `rolePermitsDevTools()` tests for `'Developer'` by name, `bootstrap.ts` sets `'Super Admin'`, and the
|
||||||
|
* users table defaults to `'Member'`. Deleting or renaming one of those is not a data change, it is a
|
||||||
|
* code change that has not happened yet — so the delete and rename paths refuse on this flag rather than
|
||||||
|
* discovering it as a runtime failure somewhere else.
|
||||||
|
*/
|
||||||
|
isSystem: boolean('is_system').notNull().default(false),
|
||||||
|
/**
|
||||||
|
* Reserved for the per-role provisioning that comes next: a script run when an account is given this
|
||||||
|
* role, so "Developer gets rootless Docker" stops being a special case in `os-user-postgres.ts` and
|
||||||
|
* becomes a row. Null today, and nothing reads it.
|
||||||
|
*
|
||||||
|
* Declared now rather than later on purpose — it is the reason the owner wanted a table at all, and a
|
||||||
|
* column that arrives with the feature that uses it is a second migration for no gain.
|
||||||
|
*/
|
||||||
|
provisioningScript: text('provisioning_script'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
@@ -38,6 +38,7 @@ export * from './anthropic-accounts/schema'; // anthropic_accounts — switchabl
|
|||||||
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account
|
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account
|
||||||
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
|
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
|
||||||
export * from './sendoff/schema'; // sendoff_devices, sendoff_senders
|
export * from './sendoff/schema'; // sendoff_devices, sendoff_senders
|
||||||
|
export * from './roles/schema'; // roles
|
||||||
export * from './agent-panels/schema'; // agent_panels
|
export * from './agent-panels/schema'; // agent_panels
|
||||||
|
|
||||||
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
|
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
|
||||||
|
|||||||
Reference in New Issue
Block a user