Grants that outlive what they name, and a rule you can read

sharing.forget_principal has existed since shares did, documented as the thing
that stops a recycled id inheriting somebody's grant, and was called by nobody.
Deleting a group left every grant naming it; deleting an account left both the
grants to it and the grants of its own work -- that second half is the one
nothing else could catch, since their rows cascade and the shares of those rows
have nothing to cascade from. Both now run before the delete, while the rows are
still findable, and a deleted resource forgets its own.

library.share defaulted to False, which meant sharing shipped documented as done
and unreachable: the panel only renders for somebody holding it, so out of the
box nobody could share anything and nothing said why. It is on.

The panel itself was checkboxes inside the resource's *save form*, listing every
group and every account on the instance, unpaginated, on every detail page -- and
a tick only took effect if you also saved the resource. It is its own routes now:
search, one grant per POST, the panel re-rendered from what is stored. Anything
already shared stays listed whatever the search says, or removing a grant would
mean searching for the name it was given to.

Reports join the shareable set and memories still do not: a finished piece of
work is the thing somebody most wants to hand over, and a record about a person
is not content to pass round. reports.visible became sharing.visible_to, which is
the one line its own docstring predicted. Two things fell out: `owned` beside
`get`, because sharing grants reading and deleting is the owner's alone; and
reading somebody else's report no longer clears their unread dot.

Permissions gained the answer to "what can this person actually do?" --
explain() is resolve()'s working shown rather than thrown away, naming admin, the
baseline, or the groups that granted each one. That is the simulation the union
rule exists to make unnecessary, and until now the only way to get it was to open
every group and read the grids by eye. Users and groups are list-plus-detail, and
membership is edited from one side: it was on both, and a full-form POST from
either overwrote what the other had shown.

Read and write are split for notes, memory and skills -- checked on the tool's
declared risk, after the gate so it can only narrow, and defaulting on.

Quotas are the union rule applied to numbers, with the corner that makes it
interesting: zero means "no limit" and wins outright, or a group saying unlimited
would count for less than one saying a million. Absent means "no opinion".
_narrower folds a group's ceiling with the instance's and is deliberately not
min, for the same reason. Five axes, enforced where each is knowable -- before a
reply is built, before a second one starts, on an agent reply's clock, before a
minute of GPU, and beside the helper cap -- and usage is recorded even for a
reply that was stopped or errored, because an endpoint charges either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-06 16:48:14 +02:00
parent 757ab305ee
commit 9d7fb72bdb
34 changed files with 2405 additions and 390 deletions
+86 -140
View File
@@ -7,157 +7,103 @@
{% block admin_content %}
<p class="admin-lede">
Everyone with an account on this instance. Administrators bypass every
permission; ordinary users get the baseline permissions plus whatever their
groups add.
Every account on this instance. Open one to see what it can actually do and
where each of those permissions came from. Group membership is edited from the
<a href="/admin/groups">group's</a> own page — one control per value, so a save
on one screen cannot undo a save on another.
</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
<form method="get" action="/admin/users" class="btn-row" style="margin-bottom: var(--sp-5)">
<input class="input" type="search" name="q" value="{{ q }}"
placeholder="Search by name or email" aria-label="Search users">
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Search</button>
{% if q %}<a class="btn btn--ghost" href="/admin/users">Clear</a>{% endif %}
</form>
<div class="filter-bar">
<form class="filter-form" method="get" action="/admin/users">
<input class="input" type="search" name="q" value="{{ q }}"
placeholder="Search by name or email…" aria-label="Search users">
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Filter</button>
{% if q %}<a class="btn btn--ghost" href="/admin/users">Clear</a>{% endif %}
</form>
</div>
<details class="card">
<summary class="card__title" style="cursor: pointer">Add a user</summary>
<form method="post" action="/admin/users" style="margin-top: var(--sp-4)">
<div class="field">
<label class="field__label" for="nu-name">Name</label>
<input class="input" id="nu-name" name="name" required>
<div class="model-rows">
{% for person in users %}
<a class="model-row" href="/admin/users/{{ person.id }}">
<div class="model-row__main">
<span class="model-row__name">
{{ person.name }}
{% if person.role == "admin" %}<span class="badge badge--leaf">admin</span>{% endif %}
{% if person.role == "pending" %}<span class="badge">pending</span>{% endif %}
{% if not person.active %}<span class="badge">disabled</span>{% endif %}
</span>
<span class="model-row__id">{{ person.email }}</span>
</div>
<div class="field">
<label class="field__label" for="nu-email">Email</label>
<input class="input" id="nu-email" name="email" type="email" required>
<div class="model-row__meta">
{% if person.groups %}
<span class="text-xs faint">
{{ person.groups | map(attribute="name") | join(", ") }}
</span>
{% endif %}
{% set spent = usage[person.id] %}
{% if spent.tokens %}
<span class="badge" title="Tokens this month">{{ "{:,}".format(spent.tokens) }}</span>
{% endif %}
</div>
<div class="field">
<label class="field__label" for="nu-password">Password</label>
<input class="input" id="nu-password" name="password" type="password"
required minlength="8" autocomplete="new-password">
<p class="field__hint">
At least 8 characters. Tell them to change it — you will know it otherwise.
</p>
</div>
<div class="field">
<label class="field__label" for="nu-role">Role</label>
<select class="select" id="nu-role" name="role">
{% for role in roles %}
<option value="{{ role }}" {{ 'selected' if role == 'user' }}>{{ role }}</option>
{% endfor %}
</select>
</a>
{% else %}
<div class="empty" style="padding: var(--sp-8) 0">
<p class="empty__text">{{ "Nobody matches that." if q else "No accounts yet." }}</p>
</div>
{% endfor %}
</div>
{% if pager.pages > 1 %}
<div class="btn-row" style="margin-top: var(--sp-5)">
{% if pager.page > 1 %}
<a class="btn btn--sm" href="/admin/users?page={{ pager.page - 1 }}{% if q %}&q={{ q|urlencode }}{% endif %}">
{{ icon("chevron-left", "icon--sm") }} Previous
</a>
{% endif %}
<span class="text-sm faint">Page {{ pager.page }} of {{ pager.pages }} · {{ pager.total }} accounts</span>
{% if pager.page < pager.pages %}
<a class="btn btn--sm" href="/admin/users?page={{ pager.page + 1 }}{% if q %}&q={{ q|urlencode }}{% endif %}">
Next {{ icon("chevron-right", "icon--sm") }}
</a>
{% endif %}
</div>
{% endif %}
<section class="card" style="margin-top: var(--sp-8)">
<h2 class="card__title">Add an account</h2>
<p class="card__lede">
Without going through registration — useful when sign-up is closed.
</p>
<form method="post" action="/admin/users" class="form-grid">
<div class="field-row">
<div class="field">
<label class="field__label" for="new-name">Name</label>
<input class="input" id="new-name" name="name" required>
</div>
<div class="field">
<label class="field__label" for="new-email">Email</label>
<input class="input" id="new-email" name="email" type="email" required>
</div>
<div class="field">
<label class="field__label" for="new-password">Password</label>
<input class="input" id="new-password" name="password" type="password" required>
</div>
<div class="field">
<label class="field__label" for="new-role">Role</label>
<select class="input" id="new-role" name="role">
{% for role in roles %}<option value="{{ role }}">{{ role }}</option>{% endfor %}
</select>
</div>
</div>
<div class="btn-row">
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} Create</button>
</div>
</form>
</details>
<h2 class="admin-section-title">
Accounts <span class="badge">{{ users|length }}</span>
</h2>
{% for account in users %}
<section class="card">
<form method="post" action="/admin/users/{{ account.id }}">
<div class="card__header">
<div class="row" style="gap: var(--sp-2); min-width: 0">
<span class="status-dot {{ 'is-ok' if account.active else 'is-off' }}"></span>
<strong class="truncate">{{ account.name }}</strong>
<code class="text-xs faint">{{ account.email }}</code>
{% if account.is_admin %}<span class="badge badge--leaf">admin</span>{% endif %}
{% if not account.active %}<span class="badge badge--danger">deactivated</span>{% endif %}
{% if account.id == user.id %}<span class="badge">you</span>{% endif %}
</div>
<span class="text-xs faint">
{% if account.last_login_at %}
last seen {{ account.last_login_at.strftime("%Y-%m-%d %H:%M") }}
{% else %}
never signed in
{% endif %}
</span>
</div>
<div class="field">
<label class="field__label" for="un-{{ account.id }}">Name</label>
<input class="input" id="un-{{ account.id }}" name="name" value="{{ account.name }}" required>
</div>
<div class="field">
<label class="field__label" for="ur-{{ account.id }}">Role</label>
<select class="select" id="ur-{{ account.id }}" name="role">
{% for role in roles %}
<option value="{{ role }}" {{ 'selected' if role == account.role }}>{{ role }}</option>
{% endfor %}
</select>
<p class="field__hint">
<strong>admin</strong> can do everything, including this page.
<strong>user</strong> is an ordinary account.
<strong>pending</strong> cannot sign in until promoted.
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="active" value="true" {{ 'checked' if account.active }}>
<span>Active — may sign in</span>
</label>
<p class="field__hint">
Deactivating signs them out everywhere immediately, rather than waiting
for their session to expire.
</p>
</div>
{% if groups %}
<div class="field">
<span class="field__label">Groups</span>
<div class="checkbox-row">
{% for group in groups %}
<label class="checkbox">
<input type="checkbox" name="group_ids" value="{{ group.id }}"
{{ 'checked' if group in account.groups }}>
<span>{{ group.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% if account.is_admin and admin_count <= 1 %}
<div class="alert alert--warning">
{{ icon("warning", "alert__icon") }}
<span>
The only administrator. Promote someone else before demoting or
deactivating this account — an instance with no admin can only be
recovered with <code>lembas create-admin</code>.
</span>
</div>
{% endif %}
<div class="btn-row"><button class="btn btn--primary" type="submit">Save</button></div>
</form>
<div class="card__footer">
<form method="post" action="/admin/users/{{ account.id }}/password" class="btn-row">
<input class="input" type="password" name="password" minlength="8"
placeholder="Set a new password" autocomplete="new-password" required
aria-label="New password for {{ account.email }}">
<button class="btn btn--sm" type="submit">{{ icon("key", "icon--sm") }} Reset</button>
</form>
{% if account.id != user.id %}
<form method="post" action="/admin/users/{{ account.id }}/delete"
data-confirm="Delete {{ account.email }} and all their chats? This cannot be undone."
data-confirm-title="Delete account">
<button class="btn btn--sm btn--danger" type="submit">
{{ icon("trash", "icon--sm") }} Delete
<button class="btn btn--primary" type="submit">
{{ icon("plus", "icon--sm") }} Create account
</button>
</form>
{% endif %}
</div>
</div>
</form>
</section>
{% endfor %}
{% endblock %}