Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/designs/822-mui-revamp.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,8 @@ Only one CSS deletion actually landed with #832: `.dialog .confirm-body` / `.dia

Ceiling **1,900 KiB**, enforced in `scripts/verify-sw.mjs` (it sums the precache manifest's listed files' real sizes from `dist/` and fails above the ceiling). Derivation, measured rather than projected: actual precache at `75388d2` (2026-09-22, after #826 and #827) is 1,806.38 KiB. Pending known costs: #828 retires the isolated `NumberField` (~-18 KiB projected in the 2026-09-16 "Numbers for the ceiling choice" issue comment — that figure covers NumberField alone; §1's fact base above already found there is no hand-rolled tooltip to also credit); #835 adopts Inter's `opsz` axis (+118.9 KiB, measured directly in D7.2 above). Net projected after both land is **~1,907 KiB — over this 1,900 ceiling by about 7 KiB, not absorbed by it.** That gap is not an oversight: D7.2 already prices it in and names the fallback for exactly this case, one display-size cut for `h1`/`h2` only, worth 24.1 KiB, comfortably more than the 7 KiB shortfall. So #835 is expected to land using that fallback, or #828 to find savings beyond the isolated-chunk estimate, or #825's own two landed slices to have left more room than this projection assumes; whichever happens, `verify-sw.mjs` will say so at build time rather than this doc guessing further. Above the ceiling a slice must retire hand-built code, or find equivalent savings elsewhere, to land; a slice that adds more than 10 KiB of precache without deleting code names the reason in its PR body as a separate documentation convention — the one the 2026-09-16 decision kept regardless of gate/no-gate — but that convention does not itself pass the check. The check runs inside the existing `web` job's `verify:sw` step, so a documentation-only PR still skips it (#782), unchanged.

**Amended 2026-10-09: the ceiling is 1,920 KiB.** The maintainer approved one raise for the OAuth milestone (#800, #798, #799), after #800 measured 1,903.38 KiB against `main`'s 1,897.12. The rest of the headroom is reserved for #798 and #799; the next raise needs a new maintainer decision. `verify-sw.mjs` carries the same record.

**Script-duration ceiling: also re-enforced by #825, as a wide regression tripwire rather than a tight budget**, for a reason the precache ceiling does not share: byte counts are environment-independent, but a CI runner's CPU is shared and variable run to run, so a tight number here would be exactly the "wrong guard that reads as safety" the owner's own 2026-09-16 comment warned against for precache. `tools/simulation/ui/specs/dashboard-script-duration.spec.ts` measures Dashboard's script execution time via CDP `Performance.getMetrics`, median of 5 runs against the real seeded stack, and fails only past a 600 ms tripwire sized generously above ordinary noise — printed every run, gating only a multi-fold regression (the record's own named unmeasured cases, `Autocomplete` and a data grid).

### D10. Coverage: the constraint nobody filed
Expand Down
4 changes: 3 additions & 1 deletion docs/schema/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions docs/schema/public.AuditEvents.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion docs/schema/viewpoint-4.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 14 additions & 2 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -729,13 +729,25 @@ against `FarmLogo`, a row shared by both the logo and the banner.
**Clearing filters (#679)** — both filtered lists that can be narrowed while
still showing rows carry a **Clear filters** control in the filter row itself,
not only inside the zero-rows empty state. On the /audit
viewer it resets all four narrowing controls (record type, action, from, to) in
one write, and deliberately **keeps the `entityId` scope**: that scope is where
viewer it resets every narrowing control (record type, action, from, to, and the
connected-app filters) in one write, and deliberately **keeps the `entityId` scope**: that scope is where
the view was opened from — a record's own "Audit history" link — not a filter
the admin set on this screen, so clearing it would silently widen the page from
one record to the whole farm. The empty state stays a plain sentence with no
action (#655).

**Connected app (#788, #800)** — an outside app, such as an AI assistant, that a
person on the farm approved to act for them through OAuth. OAuth calls it a
client; the UI always says **Connected apps**. It acts with that person's role
and flock scope, never more, so the person stays the actor on every audit event
(#500). The event also records the app's OAuth client id and its name, copied
when the person approved it and kept after the app is disconnected or removed.
The name is self-chosen at registration, so it describes the app rather than
proving who made it, and it renders as plain text. The /audit viewer shows the
person on every event's second line, adds a plug icon and "via" with the app's
name when an app acted, offers an **Only actions through connected apps**
checkbox, and puts a **Show only** button for that app inside the event.

**Hen-day % (#91, #780)** — eggs collected ÷ **recorded** hen-days × 100
(spec §19.3, amended by #780). A hen-day is one bird alive for one day; the
day's bird count comes from the bird ledger (placements + movements); every
Expand Down
13 changes: 12 additions & 1 deletion src/Cluckwork.Api/Middleware/TenantResolutionMiddleware.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
using System.Security.Claims;
using Cluckwork.Api.Modules.Access.OAuth;
using Cluckwork.Domain.Auditing;
using Cluckwork.Infrastructure.Persistence;
using OpenIddict.Abstractions;

namespace Cluckwork.Api.Middleware;

Expand Down Expand Up @@ -37,7 +41,8 @@ public async Task InvokeAsync(HttpContext context, TenantContext tenant, Current
if (Guid.TryParse(sub, out var userId))
{
user.Resolve(userId, email ?? "",
context.User.FindAll("role").Select(c => c.Value).ToList());
context.User.FindAll("role").Select(c => c.Value).ToList(),
ConnectedAppOf(context.User));
}
else
{
Expand All @@ -53,6 +58,12 @@ public async Task InvokeAsync(HttpContext context, TenantContext tenant, Current
await next(context);
}

// #800 — only an OAuth access token carries client_id; a session JWT never does.
private static ConnectedApp? ConnectedAppOf(ClaimsPrincipal principal) =>
principal.FindFirst(OpenIddictConstants.Claims.ClientId)?.Value is { Length: > 0 } clientId
? new ConnectedApp(clientId, principal.FindFirst(OAuthEndpoints.ClientNameClaim)?.Value)
: null;

private static IDisposable? ResolveAccountScope(HttpContext context, TenantContext tenant,
Serilog.IDiagnosticContext diagnosticContext, ILogger<TenantResolutionMiddleware> logger)
{
Expand Down
15 changes: 13 additions & 2 deletions src/Cluckwork.Api/Modules/Access/OAuth/OAuthEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ namespace Cluckwork.Api.Modules.Access.OAuth;

public static class OAuthEndpoints
{
// #800 — the client's display name, carried in its access tokens beside client_id.
public const string ClientNameClaim = "client_name";

// #796 — exactly the claims a session JWT carries (JwtTokenService), so the request
// chain treats an OAuth principal like a session one: tenant, actor and roles,
// flock scope, credential epoch and must-change-password all read these.
Expand Down Expand Up @@ -75,16 +78,24 @@ private sealed class AcceptsOAuthTokensMarker;
// #795 — OpenIddict has already validated the request. No consent screen exists
// until #798, so a signed-in caller approves its own, with the scopes it asked for.
// OpenIddict refuses any scope it does not register, and it registers none yet.
private static IResult Authorize(HttpContext context, ICurrentUser currentUser)
private static async Task<IResult> Authorize(
HttpContext context, ICurrentUser currentUser, IOpenIddictApplicationManager applications,
CancellationToken ct)
{
if (!currentUser.IsResolved) return Results.Unauthorized();

var request = context.GetOpenIddictServerRequest()!;
var identity = new ClaimsIdentity(
context.User.Claims.Where(claim => SessionClaimTypes.Contains(claim.Type)),
OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
Claims.Name,
Claims.Role);
identity.SetScopes(context.GetOpenIddictServerRequest()!.GetScopes());
identity.SetScopes(request.GetScopes());
// #800 — the name rides in the token, as the user's email does, so an audit row
// snapshots it without a lookup per write. Registration never renames a client.
var application = await applications.FindByClientIdAsync(request.ClientId!, ct);
identity.SetClaim(ClientNameClaim,
application is null ? null : await applications.GetDisplayNameAsync(application, ct));
identity.SetDestinations(static _ => [Destinations.AccessToken]);

return Results.SignIn(
Expand Down
6 changes: 4 additions & 2 deletions src/Cluckwork.Api/Modules/Insights/Audit/AuditEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public static RouteGroupBuilder MapAuditEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/", ListAuditEvents)
.WithName("ListAuditEvents")
.WithSummary("List audit events newest first (optional action/record type/entity/date filters, paged).");
.WithSummary("List audit events newest first (optional action/record type/entity/date/connected app filters, paged).");

return group;
}
Expand All @@ -28,6 +28,8 @@ private static async Task<IResult> ListAuditEvents(
Guid? entityId = null,
DateOnly? from = null,
DateOnly? to = null,
bool? connectedAppsOnly = null,
string? connectedAppClientId = null,
int? limit = null,
int? offset = null)
{
Expand All @@ -37,7 +39,7 @@ private static async Task<IResult> ListAuditEvents(
var skip = Math.Max(offset ?? 0, 0);

var list = await events.ListAuditEventsAsync(action, entityType,
entityId, from, to, take, skip, ct);
entityId, from, to, connectedAppsOnly ?? false, connectedAppClientId, take, skip, ct);
return Results.Ok(list);
}
}
5 changes: 5 additions & 0 deletions src/Cluckwork.Application/Common/ICurrentUser.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Cluckwork.Domain.Auditing;

namespace Cluckwork.Application.Common;

// The acting actor of the current unit of work (#93) — resolved from the JWT
Expand All @@ -18,4 +20,7 @@ public interface ICurrentUser
string Email { get; }
/// <summary>Role names from the token (#103). Empty for plain workers.</summary>
IReadOnlyList<string> Roles { get; }
/// <summary>The connected app the user acts through (#800); null for a session request.
/// Provenance only, never an authorization input.</summary>
ConnectedApp? ConnectedApp { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public interface IAuditEventRepository

Task<IReadOnlyList<AuditEventRead>> ListAsync(
string? action, string? entityType, Guid? entityId, DateOnly? from, DateOnly? to,
bool connectedAppsOnly, string? connectedAppClientId,
int limit, int offset, CancellationToken ct = default);

// #494 — created/last-changed per entity id, for one entity type. An id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ namespace Cluckwork.Application.Modules.Insights.Contracts;
public sealed record AuditEventRead(
Guid Id, DateTimeOffset OccurredAtUtc, string ActorEmail,
string Action, string EntityType, Guid EntityId,
string? Reason, string? DetailsJson);
string? Reason, string? DetailsJson,
string? ConnectedAppClientId, string? ConnectedAppName);
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ namespace Cluckwork.Application.Modules.Insights.Contracts;

public interface IInsightsModule : IReportQueries, IExportQueries
{
// #800 — connectedAppsOnly keeps actions taken through any connected app;
// connectedAppClientId keeps one app's, and implies the first.
Task<IReadOnlyList<AuditEventRead>> ListAuditEventsAsync(
string? action, string? entityType, Guid? entityId, DateOnly? from, DateOnly? to,
bool connectedAppsOnly, string? connectedAppClientId,
int limit, int offset, CancellationToken ct = default);

Task<IReadOnlyDictionary<Guid, EntityProvenance>> GetProvenanceAsync(
Expand Down
16 changes: 14 additions & 2 deletions src/Cluckwork.Domain/Auditing/AuditEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public sealed class AuditEvent : AggregateRoot<Guid>
public const int MaxEntityTypeLength = 100;
public const int MaxActorEmailLength = 256;
public const int MaxReasonLength = 500;
public const int MaxConnectedAppClientIdLength = 100;
public const int MaxConnectedAppNameLength = 100;

public DateTimeOffset OccurredAtUtc { get; private set; }
public Guid ActorUserId { get; private set; }
Expand All @@ -21,19 +23,26 @@ public sealed class AuditEvent : AggregateRoot<Guid>
public string? Reason { get; private set; }
// Plain text, not jsonb — provider-portability rule; only the API reads it.
public string? DetailsJson { get; private set; }
// #800 — null for a session request. Snapshotted like ActorEmail, so the row stays
// readable after the app is disconnected or pruned.
public string? ConnectedAppClientId { get; private set; }
public string? ConnectedAppName { get; private set; }

private AuditEvent() { }

public static AuditEvent Create(
Guid id, Guid accountId, DateTimeOffset occurredAtUtc,
Guid actorUserId, string actorEmail,
string action, string entityType, Guid entityId,
string? reason = null, string? detailsJson = null)
string? reason = null, string? detailsJson = null, ConnectedApp? connectedApp = null)
{
if (string.IsNullOrWhiteSpace(action) || action.Length > MaxActionLength)
throw new ArgumentException("A valid action code is required.", nameof(action));
if (string.IsNullOrWhiteSpace(entityType) || entityType.Length > MaxEntityTypeLength)
throw new ArgumentException("A valid entity type is required.", nameof(entityType));
if (connectedApp is not null
&& (string.IsNullOrWhiteSpace(connectedApp.ClientId) || connectedApp.ClientId.Length > MaxConnectedAppClientIdLength))
throw new ArgumentException("A connected app needs a valid client id.", nameof(connectedApp));

return new AuditEvent
{
Expand All @@ -49,7 +58,10 @@ public static AuditEvent Create(
? null
: reason.Trim().Length > MaxReasonLength
? reason.Trim()[..MaxReasonLength] : reason.Trim(),
DetailsJson = detailsJson
DetailsJson = detailsJson,
ConnectedAppClientId = connectedApp?.ClientId,
ConnectedAppName = connectedApp?.Name is { Length: > MaxConnectedAppNameLength } name
? name[..MaxConnectedAppNameLength] : connectedApp?.Name
};
}
}
6 changes: 6 additions & 0 deletions src/Cluckwork.Domain/Auditing/ConnectedApp.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Cluckwork.Domain.Auditing;

// #800 — the connected app (OAuth client) a person acted through. The person stays the
// actor (#500); this is provenance beside them. Name is the client's self-asserted DCR
// name, already stripped of controls and bidi characters (#797), and may be absent.
public sealed record ConnectedApp(string ClientId, string? Name);
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public sealed class AuditEventRepository(AppDbContext db, TenantContext tenant)
{
public async Task<IReadOnlyList<AuditEventRead>> ListAsync(
string? action, string? entityType, Guid? entityId, DateOnly? from, DateOnly? to,
bool connectedAppsOnly, string? connectedAppClientId,
int limit, int offset, CancellationToken ct = default)
{
// Date filters are inclusive calendar days over the UTC timestamp.
Expand All @@ -30,7 +31,9 @@ public async Task<IReadOnlyList<AuditEventRead>> ListAsync(
&& (entityType == null || e.EntityType == entityType)
&& (entityId == null || e.EntityId == entityId)
&& (fromUtc == null || e.OccurredAtUtc >= fromUtc)
&& (toUtc == null || e.OccurredAtUtc < toUtc))
&& (toUtc == null || e.OccurredAtUtc < toUtc)
&& (!connectedAppsOnly || e.ConnectedAppClientId != null)
&& (connectedAppClientId == null || e.ConnectedAppClientId == connectedAppClientId))
// #508 — "Sequence" tiebreaker, not "Id": same-instant events must
// page stably AND in the order they were written. "Id" is a random
// v4 Guid, so it gave a stable-but-arbitrary order.
Expand All @@ -41,7 +44,7 @@ public async Task<IReadOnlyList<AuditEventRead>> ListAsync(

return events.Select(e => new AuditEventRead(
e.Id, e.OccurredAtUtc, e.ActorEmail, e.Action, e.EntityType, e.EntityId,
e.Reason, e.DetailsJson)).ToList();
e.Reason, e.DetailsJson, e.ConnectedAppClientId, e.ConnectedAppName)).ToList();
}

// #494 — who created a record and who last changed it, read out of the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,9 +270,11 @@ private sealed class AsyncDisposableAction(Func<ValueTask> action) : IAsyncDispo
.OrderBy(x => x.OccurredAtUtc)
.ThenBy(x => EF.Property<long>(x, "Sequence")),
["id", "occurredAtUtc", "actorUserId", "actorEmail", "action",
"entityType", "entityId", "reason", "detailsJson"],
"entityType", "entityId", "reason", "detailsJson",
"connectedAppClientId", "connectedAppName"],
x => [x.Id, x.OccurredAtUtc, x.ActorUserId, x.ActorEmail, x.Action,
x.EntityType, x.EntityId, x.Reason, x.DetailsJson]),
x.EntityType, x.EntityId, x.Reason, x.DetailsJson,
x.ConnectedAppClientId, x.ConnectedAppName]),

_ => null,
};
Expand Down
Loading