From 82f77db5c01e1b6ee159a6dde75686743910bf23 Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 25 Aug 2026 17:11:51 +0200 Subject: [PATCH] feat(workshop): retain daily assistant quotas --- apps/docs/docs/workshop/homarr-provider.mdx | 6 ++-- apps/docs/docs/workshop/operator.mdx | 3 ++ apps/workshop/homarr_provider.go | 12 ++++--- .../1787670446_daily_assistant_quotas.js | 36 +++++++++++++++++++ apps/workshop/tests/workshop-contracts.mjs | 8 +++++ apps/workshop/tests/workshop.integration.mjs | 16 +++++++-- 6 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js diff --git a/apps/docs/docs/workshop/homarr-provider.mdx b/apps/docs/docs/workshop/homarr-provider.mdx index 569a8499c..aa0ae7058 100644 --- a/apps/docs/docs/workshop/homarr-provider.mdx +++ b/apps/docs/docs/workshop/homarr-provider.mdx @@ -38,8 +38,10 @@ its server; changing the browser clock cannot bypass them. Workshop keeps the OpenRouter API key on its server. Homarr and the browser never receive it. Prompts, attachments, tools, and tool results pass through Workshop and OpenRouter. Workshop does not retain this -content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. OpenRouter is -configured for zero data retention with data collection disabled. Homarr does not use this content to train models. +content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. Each active day +has a separate quota record so Workshop operators can inspect per-user daily usage without seeing request content. +OpenRouter is configured for zero data retention with data collection disabled. Homarr does not use this content to +train models. We would rather become millionaires in a more ethical manner. diff --git a/apps/docs/docs/workshop/operator.mdx b/apps/docs/docs/workshop/operator.mdx index 9794f8a2a..777562c66 100644 --- a/apps/docs/docs/workshop/operator.mdx +++ b/apps/docs/docs/workshop/operator.mdx @@ -72,6 +72,9 @@ per-request telemetry. It accepts only uploaded image data, conservatively bound allows at most two concurrent requests per user and 100 across the service. Keep an OpenRouter account-level credit limit as the final monetary backstop because request quotas are not dollar accounting. +PocketBase stores one `assistant_quotas` record per user and active UTC day. Workshop superusers can use this private +collection for a daily usage breakdown; regular users cannot list or view its records. + Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement, failed-request accounting, identity attribution, server-selected model routing, function tools, input and output controls, privacy routing, spoofing resistance, absence of request logs, and migration rollback. diff --git a/apps/workshop/homarr_provider.go b/apps/workshop/homarr_provider.go index 8129d5130..0966246d4 100644 --- a/apps/workshop/homarr_provider.go +++ b/apps/workshop/homarr_provider.go @@ -605,7 +605,12 @@ func (provider *homarrProvider) loadQuota( user *core.Record, now time.Time, ) (*core.Record, quotaSnapshot, bool, error) { - quota, err := app.FindFirstRecordByFilter("assistant_quotas", "user = {:user}", dbx.Params{"user": user.Id}) + day := now.Format(time.DateOnly) + quota, err := app.FindFirstRecordByFilter( + "assistant_quotas", + "user = {:user} && day = {:day}", + dbx.Params{"user": user.Id, "day": day}, + ) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, quotaSnapshot{}, false, err } @@ -617,10 +622,7 @@ func (provider *homarrProvider) loadQuota( } quota = core.NewRecord(collection) quota.Set("user", user.Id) - changed = true - } - if quota.GetString("day") != now.Format(time.DateOnly) { - quota.Set("day", now.Format(time.DateOnly)) + quota.Set("day", day) quota.Set("used", 0) changed = true } diff --git a/apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js b/apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js new file mode 100644 index 000000000..cd34c090c --- /dev/null +++ b/apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js @@ -0,0 +1,36 @@ +/// + +migrate( + (app) => { + const quotas = app.findCollectionByNameOrId("assistant_quotas"); + quotas.removeIndex("idx_assistant_quotas_user"); + quotas.addIndex("idx_assistant_quotas_user_day", true, "user, day", ""); + app.save(quotas); + }, + (app) => { + const quotas = app.findCollectionByNameOrId("assistant_quotas"); + quotas.removeIndex("idx_assistant_quotas_user_day"); + + // The previous schema can keep only one record per user. Retain the latest UTC day when rolling back. + app + .db() + .newQuery(` + DELETE FROM assistant_quotas + WHERE id NOT IN ( + SELECT ( + SELECT candidate.id + FROM assistant_quotas candidate + WHERE candidate.user = quota_user.user + ORDER BY candidate.day DESC, candidate.created DESC, candidate.id DESC + LIMIT 1 + ) + FROM assistant_quotas quota_user + GROUP BY quota_user.user + ) + `) + .execute(); + + quotas.addIndex("idx_assistant_quotas_user", true, "user", ""); + app.save(quotas); + }, +); diff --git a/apps/workshop/tests/workshop-contracts.mjs b/apps/workshop/tests/workshop-contracts.mjs index f38dc0bca..f507b1d6e 100644 --- a/apps/workshop/tests/workshop-contracts.mjs +++ b/apps/workshop/tests/workshop-contracts.mjs @@ -82,6 +82,14 @@ for (const forbidden of ['name: "assistant_requests"', 'name: "assistant_activit if (providerMigration.includes(forbidden)) throw new Error(`Homarr provider must not persist ${forbidden}`); } +const dailyQuotaMigration = await read("apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js"); +for (const required of [ + 'removeIndex("idx_assistant_quotas_user")', + 'addIndex("idx_assistant_quotas_user_day", true, "user, day", "")', +]) { + if (!dailyQuotaMigration.includes(required)) throw new Error(`Daily quota migration is missing: ${required}`); +} + const providerBackend = await read("apps/workshop/homarr_provider.go"); for (const required of [ '"homarr/model"', diff --git a/apps/workshop/tests/workshop.integration.mjs b/apps/workshop/tests/workshop.integration.mjs index 2b93f44ca..18f5c32b5 100644 --- a/apps/workshop/tests/workshop.integration.mjs +++ b/apps/workshop/tests/workshop.integration.mjs @@ -436,9 +436,21 @@ const resetUsage = await request("/api/ai/usage", { headers: authorSession.heade if (resetUsage.limit !== 50 || resetUsage.used !== 0 || resetUsage.remaining !== 50) { throw new Error(`Quota did not reset at the UTC day boundary: ${JSON.stringify(resetUsage)}`); } -const resetQuota = ( +const today = new Date().toISOString().slice(0, 10); +const resetQuotas = ( await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders }) -).items.find((item) => item.user === author.id); +).items.filter((item) => item.user === author.id); +const historicalQuota = resetQuotas.find((item) => item.id === authorQuota.id); +const resetQuota = resetQuotas.find((item) => item.day === today); +if ( + resetQuotas.length !== 2 || + historicalQuota?.day !== "2000-01-01" || + historicalQuota.used !== 49 || + !resetQuota || + resetQuota.used !== 0 +) { + throw new Error(`Daily quota history was not preserved: ${JSON.stringify(resetQuotas)}`); +} await request(`/api/collections/assistant_quotas/records/${resetQuota.id}`, { method: "PATCH", headers: rootHeaders,