style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)

Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-04 15:58:06 +02:00
committed by GitHub
co-authored by Claude Sonnet 5
parent a5154ee884
commit ec27228a8e
1588 changed files with 28388 additions and 10890 deletions
+3 -3
View File
@@ -69,7 +69,7 @@ import {
} from '../schemas'
// ============================================================
// Helpers — minimal valid objects for composition
// Helpers: minimal valid objects for composition
// ============================================================
const validUuid = '550e8400-e29b-41d4-a716-446655440000'
@@ -401,7 +401,7 @@ describe('UpdateInvoiceSchema', () => {
expect(result.success).toBe(false)
})
it('drops save_as_draft — editing a draft never re-creates it', () => {
it('drops save_as_draft: editing a draft never re-creates it', () => {
const result = UpdateInvoiceSchema.safeParse(validInvoice({ save_as_draft: true }))
expect(result.success).toBe(true)
if (result.success) {
@@ -1589,7 +1589,7 @@ describe('CreateDeadlineSchema', () => {
deadline_type: 'tax',
due_time: '25:00',
})
// Note: regex accepts 25:00 — business logic validates actual time values
// Note: regex accepts 25:00: business logic validates actual time values
// This test documents the current behavior
const parsed = CreateDeadlineSchema.safeParse({
title: 'Test',
+1 -1
View File
@@ -191,7 +191,7 @@ describe('validateQuery', () => {
expect(result.success).toBe(false)
if (!result.success) {
// The response is a NextResponse — we verify it's a 400
// The response is a NextResponse: we verify it's a 400
expect(result.response.status).toBe(400)
}
})
+4 -4
View File
@@ -3,7 +3,7 @@
*
* Use case: an agent (MCP, automation webhook, scripted client) retries an
* operation after a network blip. Without idempotency, the retry creates a
* duplicate side-effect — two invoices, two journal entries, two emails.
* duplicate side-effect: two invoices, two journal entries, two emails.
*
* Contract:
* 1. The caller supplies an `idempotency_key` per logical operation.
@@ -18,7 +18,7 @@
* the wrong company can never receive the other company's cached response.
*
* 24-hour TTL is enforced by an `expires_at` column + a cleanup cron. After
* 24h, the same key may be reused safely — agents that retry that long after
* 24h, the same key may be reused safely: agents that retry that long after
* the original request are not retrying, they're starting over.
*/
import crypto from 'crypto'
@@ -64,7 +64,7 @@ function canonicalJson(value: unknown): string {
* without side-effects)
*
* Throws IdempotencyKeyReuseError when the key exists with a *different*
* request hash — the caller is misusing the key.
* request hash: the caller is misusing the key.
*/
export async function checkIdempotencyKey(
supabase: SupabaseClient,
@@ -125,7 +125,7 @@ export async function storeIdempotencyResponse(
response_body: body,
})
// Postgres 23505 is unique_violation — a concurrent retry already inserted.
// Postgres 23505 is unique_violation: a concurrent retry already inserted.
// Silently OK; the cached response from the winner will be returned to
// both callers on subsequent reads.
if (error && error.code !== '23505') {
+68 -68
View File
@@ -16,22 +16,22 @@ const uuid = z.string().uuid()
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
/**
* ISO date that must also be a real, in-range calendar date — not just the
* ISO date that must also be a real, in-range calendar date: not just the
* right shape. Backed by the shared `isSaneDateString` rule (also used by the
* transaction form) so a 6-digit year or impossible date can't slip through
* for user-entered dates. Use this over `isoDate` for free-text date input.
*/
const saneIsoDate = z
.string()
.refine(isSaneDateString, 'Invalid or out-of-range date (expected YYYY-MM-DD, year 1900–2100)')
.refine(isSaneDateString, 'Invalid or out-of-range date (expected YYYY-MM-DD, year 1900-2100)')
/** BAS account number — always a string of 4 digits */
/** BAS account number: always a string of 4 digits */
const accountNumber = z.string().regex(/^\d{4}$/, 'Account number must be exactly 4 digits')
/** Non-negative monetary amount (>= 0) */
const nonNegativeAmount = z.number().nonnegative()
/** BAS class-3 revenue account — exactly 4 digits starting with 3 (försäljning/intäkt). */
/** BAS class-3 revenue account: exactly 4 digits starting with 3 (försäljning/intäkt). */
const revenueAccount = z
.string()
.regex(/^3\d{3}$/, 'Revenue account must be a 4-digit BAS class-3 account (3xxx)')
@@ -54,7 +54,7 @@ const deferredRevenueAccount = z
/**
* Shared periodisering period rules for invoice line items: both dates or
* neither, end after start, and a 2–120 calendar month span. The amount-side
* neither, end after start, and a 2-120 calendar month span. The amount-side
* rules differ per item shape and stay in each schema's superRefine.
*/
function validateAccrualPeriod(
@@ -106,12 +106,12 @@ export const EntityTypeSchema = z.enum(['enskild_firma', 'aktiebolag'])
export const AccountingFrameworkSchema = z.enum(['k2', 'k3'])
/**
* Single K3 component (BFNAR 2012:1 ch.17.4 — komponentavskrivning).
* Single K3 component (BFNAR 2012:1 ch.17.4, komponentavskrivning).
*
* Used inside AssetCreateSchema / AssetUpdateSchema's `k3_components` array.
* The cross-component invariant (sum of `cost` equals asset `acquisition_cost`)
* lives in `validateComponents` from `lib/bokslut/assets/k3-components.ts`
* and is called by the route-layer refinement — it cannot be expressed in
* and is called by the route-layer refinement: it cannot be expressed in
* a single-object schema. Component-level checks (cost > 0, salvage ≤ cost,
* positive useful life) are reinforced by `validateComponents` too so any
* future caller that uses just the validator gets the same guarantees.
@@ -210,7 +210,7 @@ export const JournalEntrySourceTypeSchema = z.enum([
/** Query params for GET /api/bookkeeping/voucher-sequences/next. */
export const VoucherSequenceNextQuerySchema = z.object({
period_id: uuid.optional(),
series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z').optional(),
series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z').optional(),
source_type: JournalEntrySourceTypeSchema.optional(),
date: isoDate.optional(),
})
@@ -278,7 +278,7 @@ export const CreateInvoiceItemSchema = z
article_id: uuid.nullable().optional(),
revenue_account: revenueAccount.nullable().optional(),
// ROT/RUT-avdrag fields. `deduction_amount` is intentionally omitted from
// the client schema — the API computes it from rot-rut-rules.ts so a
// the client schema: the API computes it from rot-rut-rules.ts so a
// tampered client can't expand the 1513 receivable beyond the line total.
deduction_type: z.enum(['rot', 'rut']).nullable().optional(),
labor_hours: z.number().nonnegative().nullable().optional(),
@@ -286,8 +286,8 @@ export const CreateInvoiceItemSchema = z
housing_designation: z.string().max(128).nullable().optional(),
apartment_number: z.string().max(32).nullable().optional(),
// Bostadsrättsföreningens orgnr (XSD BrfOrgNrTYPE). If present it must be
// a real orgnr shape — 10 digits (optional dash after position 6) or the
// 12-digit sekelsiffra form — or Skatteverkets schemavalidering rejects
// a real orgnr shape: 10 digits (optional dash after position 6) or the
// 12-digit sekelsiffra form, or Skatteverkets schemavalidering rejects
// the whole file at upload time. Empty string = cleared field → null.
brf_org_number: z
.union([
@@ -362,7 +362,7 @@ export const CreateInvoiceSchema = z.object({
// ROT/RUT claim info. The personnummer is plaintext on the wire and gets
// encrypted server-side before it ever hits the DB (see encryptPersonnummer
// in lib/salary/personnummer.ts). `deduction_housing_designation` is the
// fastighetsbeteckning at invoice level — required when any ROT item is
// fastighetsbeteckning at invoice level: required when any ROT item is
// present (enforced via rot-rut-rules.validateInvoice in the API).
deduction_personnummer: z.string().max(20).optional(),
deduction_housing_designation: z.string().max(128).optional(),
@@ -393,7 +393,7 @@ export const CreateInvoiceSchema = z.object({
})
// Update (edit) an existing DRAFT invoice in place. Same shape as create minus
// `save_as_draft` — editing never (re)creates a draft or allocates a number, it
// `save_as_draft`: editing never (re)creates a draft or allocates a number, it
// only rewrites the draft's header + line items. The PATCH route guards that the
// target is still a draft (status='draft', no journal entry, not self-billed).
export const UpdateInvoiceSchema = CreateInvoiceSchema.omit({ save_as_draft: true })
@@ -410,7 +410,7 @@ export const CreateCreditNoteSchema = z.object({
export const RotRutPayoutFileSchema = z.object({
deduction_type: z.enum(['rot', 'rut']),
invoice_ids: z.array(uuid).min(1).max(500),
// NamnPaBegaran — the XSD caps it at 16 chars; omitted → generated.
// NamnPaBegaran: the XSD caps it at 16 chars; omitted → generated.
name: z.string().min(1).max(16).optional(),
})
@@ -468,7 +468,7 @@ export const UpdateArticleSchema = CreateArticleSchema.partial().extend({
// issued the invoice on our behalf; for us it is a sale. We store the
// counterparty's number in external_invoice_number and never assign one from
// our own series. No ROT/RUT (that is a B2C, own-issued concept), so the item
// schema is the lean revenue-only shape — vat_rate is constrained to the legal
// schema is the lean revenue-only shape: vat_rate is constrained to the legal
// Swedish set so the booked output VAT is always reportable.
export const SelfBillingInvoiceItemSchema = z.object({
description: z.string().min(1, 'Item description is required'),
@@ -496,7 +496,7 @@ export const CreateSelfBillingInvoiceSchema = z.object({
// Recurring invoice schedule schemas
// ============================================================
// Swedish VAT rates per ML 17 kap 24§ p.9 — null means "use customer default
// Swedish VAT rates per ML 17 kap 24§ p.9: null means "use customer default
// from getAvailableVatRates". Any other value would produce a non-compliant
// invoice (buyer cannot deduct ingående moms). Cron-time validation against
// the customer's allowed set still runs in executeRecurringSchedule.
@@ -558,7 +558,7 @@ export const MarkInvoicePaidSchema = z.object({
// Bypass the duplicate-payment guard. Set after the user reviews the
// candidate list returned by INVOICE_PAID_LIKELY_DUPLICATE and confirms
// none of them are this payment. v1 callers must use a fresh
// Idempotency-Key on the retry — the original is body-hash bound.
// Idempotency-Key on the retry: the original is body-hash bound.
force: z.boolean().optional(),
})
@@ -636,7 +636,7 @@ export const CreateSupplierInvoiceItemSchema = z.object({
vat_amount: z.number().min(0).optional(),
// Self-assessed VAT rate for omvänd skattskyldighet (reverse charge). The
// supplier charges no VAT (vat_rate stays 0); this is the Swedish statutory
// rate the buyer self-assesses at — 25% huvudregel default, 12%/6% for
// rate the buyer self-assesses at: 25% huvudregel default, 12%/6% for
// reduced-rated services (ML 6 kap 34 §). Must be a statutory rate.
reverse_charge_rate: z
.number()
@@ -764,7 +764,7 @@ export const CreateJournalEntryLineSchema = z.object({
// the staged pending-operations path validates identically. Wins per key
// over the cost_center/project aliases.
dimensions: DimensionsBagSchema.optional(),
// Deprecated aliases for dimensions['1'] / dimensions['6'] — kept forever
// Deprecated aliases for dimensions['1'] / dimensions['6'], kept forever
// for API/MCP compatibility.
cost_center: z.string().optional(),
project: z.string().optional(),
@@ -776,7 +776,7 @@ export const CreateJournalEntrySchema = z.object({
description: z.string().min(1, 'Description is required'),
source_type: JournalEntrySourceTypeSchema.default('manual'),
source_id: z.string().optional(),
voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z').optional(),
voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z').optional(),
notes: z.string().max(2000).optional(),
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
})
@@ -794,7 +794,7 @@ export const CorrectJournalEntrySchema = z.object({
/**
* Object code for USER-CREATED dimension values: strict Fortnox format.
* Deliberately tighter than both the DB CHECK (1..40 chars, no `"{}`') and
* DimensionsBagSchema (line-level values) — legacy free-text codes from the
* DimensionsBagSchema (line-level values): legacy free-text codes from the
* backfill/SIE import must survive on lines, but new registry codes minted
* through the API stay portable to Fortnox/Visma.
*/
@@ -802,7 +802,7 @@ const dimensionValueCode = z
.string()
.regex(
/^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$/,
'Koden får bara innehålla bokstäver (A–Ö), siffror, _, + och - (max 20 tecken)',
'Koden får bara innehålla bokstäver (A-Ö), siffror, _, + och - (max 20 tecken)',
)
const dimensionValueDates = {
@@ -810,7 +810,7 @@ const dimensionValueDates = {
end_date: isoDate.nullable().optional(),
}
/** PATCH /api/dimensions/[id] — name is rejected route-side for is_system dims. */
/** PATCH /api/dimensions/[id]: name is rejected route-side for is_system dims. */
export const UpdateDimensionSchema = z
.object({
name: z.string().min(1).max(80).optional(),
@@ -821,7 +821,7 @@ export const UpdateDimensionSchema = z
message: 'Minst ett fält måste anges',
})
/** POST /api/dimensions/[id]/values — code is immutable after creation (v1: no rename). */
/** POST /api/dimensions/[id]/values: code is immutable after creation (v1: no rename). */
export const CreateDimensionValueSchema = z
.object({
code: dimensionValueCode,
@@ -836,7 +836,7 @@ export const CreateDimensionValueSchema = z
)
/**
* POST /api/bookkeeping/journal-entry-lines/[lineId]/retag — Tier-2 retro-
* POST /api/bookkeeping/journal-entry-lines/[lineId]/retag: Tier-2 retro-
* tagging (dimensions plan PR6). The RPC enforces every rule (posted only,
* open period, lock date, active registry values); this schema only shapes
* the request. An empty bag {} untags the line.
@@ -908,7 +908,7 @@ export const RetagLineDimensionsSchema = z.object({
reason: z.string().min(3).max(500),
})
/** PATCH /api/dimensions/[id]/values/[valueId] — no `code` field by design. */
/** PATCH /api/dimensions/[id]/values/[valueId]: no `code` field by design. */
export const UpdateDimensionValueSchema = z
.object({
name: z.string().min(1).max(120).optional(),
@@ -921,7 +921,7 @@ export const UpdateDimensionValueSchema = z
/**
* Move a posted verifikation to a different date (and thereby fiscal period)
* without changing its lines — fixes a booking entered with the wrong
* without changing its lines: fixes a booking entered with the wrong
* date/year. The corrected lines are copied server-side from the original.
*/
export const RecordateJournalEntrySchema = z.object({
@@ -966,7 +966,7 @@ export const CategorizeTransactionSchema = z
// the specific already-booked candidate (re-detected server-side, so a
// guessed id can't wave the guard away). The candidate is either a sibling
// transaction (expected_duplicate_transaction_id) or a ledger-only voucher
// with no transaction behind it (expected_duplicate_journal_entry_id) — both
// with no transaction behind it (expected_duplicate_journal_entry_id): both
// carry a journal_entry_id, so new callers bind on that.
force: z.boolean().optional(),
expected_duplicate_transaction_id: uuid.optional(),
@@ -983,7 +983,7 @@ export const BookTransactionSchema = z
entry_date: isoDate,
description: z.string().min(1, 'Description is required'),
lines: z.array(CreateJournalEntryLineSchema).min(1, 'At least one line is required'),
// Booking-time duplicate guard — see CategorizeTransactionSchema.
// Booking-time duplicate guard: see CategorizeTransactionSchema.
force: z.boolean().optional(),
expected_duplicate_transaction_id: uuid.optional(),
expected_duplicate_journal_entry_id: uuid.optional(),
@@ -994,7 +994,7 @@ export const BookTransactionSchema = z
})
/**
* Edit a bank transaction's title (description). Only the working label —
* Edit a bank transaction's title (description). Only the working label:
* gated server-side to unbooked, unmatched rows. Trimmed; whitespace-only is
* rejected by min(1). Passing the bank original restores the "not edited" tag.
*/
@@ -1015,7 +1015,7 @@ export const BookInboxItemDirectlySchema = z.object({
* Bulk-book selected Underlag (Dokumentinkorgen) against their matched bank
* transactions. One shared category + VAT treatment is applied to every
* selected item; each item is booked against its own matched transaction (which
* carries the SEK amount), so the verifikat are individual — not a
* carries the SEK amount), so the verifikat are individual, not a
* samlingsverifikation. Items without a matched transaction, already booked, or
* already linked to a leverantörsfaktura are skipped server-side.
*
@@ -1046,7 +1046,7 @@ export const MatchInvoiceSchema = z
// Bypass the soft-duplicate guard (MATCH_INVOICE_POSSIBLE_DUPLICATE).
// Set after the user reviews the candidate verifikation and confirms it
// is not this payment. v1 callers must use a fresh Idempotency-Key on
// the retry — the original is body-hash bound.
// the retry: the original is body-hash bound.
force: z.boolean().optional(),
// Required whenever force=true. Echoes the journal_entry_id of the
// candidate the user reviewed in the duplicate-payment-check pre-flight.
@@ -1056,7 +1056,7 @@ export const MatchInvoiceSchema = z
// force=true to bypass the guard without ever consulting the candidate.
expected_journal_entry_id: uuid.optional(),
// Optional user-edited journal entry lines. When present they override
// the default clearing/cash booking — the route validates balance and
// the default clearing/cash booking: the route validates balance and
// posts via createJournalEntry directly. Source_type is still set from
// the routing decision (invoice_paid vs invoice_cash_payment) so
// downstream payment-sync continues to work.
@@ -1068,11 +1068,11 @@ export const MatchInvoiceSchema = z
})).min(2).optional(),
// Optional caller-supplied SEK-per-invoice-currency rate for cross-currency
// settlement. Used when the Riksbanken lookup returns nothing (rate not
// published for that date) — the dialog surfaces an input so the user can
// published for that date): the dialog surfaces an input so the user can
// type the rate from their bank statement. Ignored when tx.currency ===
// invoice.currency. The .max() is a sanity ceiling against pasted garbage /
// scientific-notation input silently corrupting the FX-diff posting and
// invoice_payments.amount — no supported currency's SEK rate approaches it
// invoice_payments.amount: no supported currency's SEK rate approaches it
// (USD~10.5, EUR~11.5, GBP~13.5). It is a guard rail, not a precise band;
// the dialog's live preview (paid_in_invoice_currency + FX gain/loss) is
// what catches a plausible-but-wrong decimal-shift typo before confirm.
@@ -1085,7 +1085,7 @@ export const MatchInvoiceSchema = z
/**
* Link an existing posted verifikat as payment for an invoice. No new
* journal entry is created — only an invoice_payments row pointing at the
* journal entry is created: only an invoice_payments row pointing at the
* supplied journal_entry_id, plus the invoice's paid/remaining are advanced.
*/
export const LinkInvoiceToVoucherSchema = z.object({
@@ -1095,7 +1095,7 @@ export const LinkInvoiceToVoucherSchema = z.object({
/**
* Supplier-invoice mirror: link an existing posted verifikat as payment for a
* supplier invoice. No new JE — only a supplier_invoice_payments row pointing
* supplier invoice. No new JE: only a supplier_invoice_payments row pointing
* at the supplied journal_entry_id, plus the invoice's paid/remaining advance.
*/
export const LinkSupplierInvoiceToVoucherSchema = z.object({
@@ -1116,7 +1116,7 @@ export const LinkSupplierInvoiceToVoucherSchema = z.object({
* expansion (one_line_per_tx OR sum_per_account) and passes the final
* lines to the RPC.
*
* Exactly one of the two paths must be set — enforced by superRefine.
* Exactly one of the two paths must be set: enforced by superRefine.
*/
export const BulkBookSchema = z
.object({
@@ -1207,7 +1207,7 @@ export const MatchBatchSchema = z
z.object({
kind: z.literal('customer_invoice'),
invoice_id: uuid,
// Strictly positive — zero or negative is rejected at the schema
// Strictly positive: zero or negative is rejected at the schema
// layer (PR #603 review) so the RPC's BATCH_INVALID_AMOUNT path
// is only reachable from non-HTTP callers.
amount: z.number().positive('Allocation amount must be greater than 0'),
@@ -1221,12 +1221,12 @@ export const MatchBatchSchema = z
)
.min(1, 'At least one allocation is required')
// Cap at 100 to prevent DoS via unbounded FOR UPDATE locks in the RPC
// (PR #603 compliance review — OWASP V4.2). Domain-appropriate ceiling:
// (PR #603 compliance review, OWASP V4.2). Domain-appropriate ceiling:
// a real samlingsverifikat rarely covers more than a few dozen invoices.
.max(100, 'At most 100 allocations per batch'),
})
.superRefine((data, ctx) => {
// Reject mixed customer + supplier in a single batch — semantically a
// Reject mixed customer + supplier in a single batch: semantically a
// single bank transfer settles invoices on one side. The RPC also guards
// this with BATCH_MIXED_KINDS_UNSUPPORTED, but rejecting at the schema
// layer gives a cleaner 400 with a per-field path.
@@ -1259,7 +1259,7 @@ export const CreateTransactionFromDocumentSchema = z.object({
export const MatchSupplierInvoiceSchema = z.object({
supplier_invoice_id: uuid,
// Same purpose as MatchInvoiceSchema.lines — user-edited rows override
// Same purpose as MatchInvoiceSchema.lines: user-edited rows override
// the default 2440-clearing / cash booking. Route validates balance and
// posts via createJournalEntry; source_type still derives from routing.
lines: z.array(z.object({
@@ -1275,7 +1275,7 @@ export const MatchSupplierInvoiceSchema = z.object({
// Settings schemas
// ============================================================
// Editable invoice email texts (standard invoices only). Nested JSONB —
// Editable invoice email texts (standard invoices only). Nested JSONB:
// unknown keys inside are stripped (Zod default, consistent with this file).
// Empty strings pass validation; the template resolver treats whitespace-only
// as unset, and the UI prunes empties before saving so the stored object
@@ -1348,15 +1348,15 @@ export const UpdateSettingsSchema = z.object({
bookkeeping_locked_through: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Ogiltigt datumformat (YYYY-MM-DD)').nullable().optional(),
auto_lock_period_days: z.number().int().positive().nullable().optional(),
// Voucher series
default_voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z').optional(),
default_voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z').optional(),
// Per-source-type voucher series map. Keys are journal_entries.source_type
// values; values are single uppercase letters A–Z. Read by the engine
// values; values are single uppercase letters A-Z. Read by the engine
// (`createDraftEntry`) when no explicit voucher_series is passed, with a
// fallback to 'A' for unknown keys.
default_voucher_series_per_source_type: z
.record(
JournalEntrySourceTypeSchema,
z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z'),
z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z'),
)
.optional(),
// Invoice PDF settings
@@ -1370,11 +1370,11 @@ export const UpdateSettingsSchema = z.object({
invoice_company_name_position: z.enum(['header', 'footer']).optional(),
invoice_late_fee_text: z.string().nullable().optional(),
invoice_credit_terms_text: z.string().nullable().optional(),
// Editable invoice email texts — { sv?: {...}, en?: {...} }; null clears
// Editable invoice email texts: { sv?: {...}, en?: {...} }; null clears
// all overrides. Without this entry the generic PUT would silently strip
// the field (the schema is the de-facto column whitelist).
invoice_email_texts: InvoiceEmailTextsSchema.nullable().optional(),
// Invoice branding — colors enforced as #RRGGBB at the DB level too
// Invoice branding: colors enforced as #RRGGBB at the DB level too
// (see migration 20260526120200_invoice_branding.sql). The dedicated
// /api/settings/invoicing/branding route is the primary path; these
// entries let the generic PUT /api/settings also accept the same fields.
@@ -1406,7 +1406,7 @@ export const UpdateSettingsSchema = z.object({
.optional(),
// AI agent flow
ai_flow_enabled: z.boolean().optional(),
// Dimensions (kostnadsställe/projekt) — UI-visibility toggle only, never
// Dimensions (kostnadsställe/projekt): UI-visibility toggle only, never
// load-bearing for correctness (dev_docs/dimensions_implementation_plan.md §2).
dimensions_enabled: z.boolean().optional(),
// Salary payment file
@@ -1554,7 +1554,7 @@ export const RunReconciliationSchema = z.object({
.optional(),
dry_run: z.boolean().optional(),
// Pairs the user ticked in the dry-run preview. When present on an apply
// (dry_run false), only these pairs are committed — intersected server-side
// (dry_run false), only these pairs are committed: intersected server-side
// with a fresh match run, so a stale or fabricated pair is never applied.
selected_matches: z
.array(
@@ -1588,10 +1588,10 @@ export const AccountBalancesQuerySchema = z.object({
.string()
.transform((s) => s.split(',').map((a) => a.trim()).filter(Boolean))
.pipe(z.array(accountNumber).min(1).max(50)),
// Reject future dates — a saldo "as of tomorrow" would include unposted
// Reject future dates: a saldo "as of tomorrow" would include unposted
// future entries (if any) and mislead the bookkeeper about the true
// pre-entry state of the ledger. Compared in Europe/Stockholm so a Swedish
// bookkeeper working in the 00:00–02:00 CET window (after midnight UTC has
// bookkeeper working in the 00:00-02:00 CET window (after midnight UTC has
// not yet passed) isn't rejected for entering their local today's date.
as_of: isoDate.refine(
(d) => d <= new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Stockholm' }),
@@ -1649,7 +1649,7 @@ export const PendingOperationsBulkSchema = z.object({
export const VoucherGapQuerySchema = z.object({
fiscal_period_id: uuid,
voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z').optional(),
voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z').optional(),
})
export const SaveGapExplanationSchema = z.object({
@@ -1796,7 +1796,7 @@ export const SalaryLineItemTypeSchema = z.enum([
'correction', 'other',
])
// Base employee object (no refinements — safe for .partial())
// Base employee object (no refinements, safe for .partial())
const EmployeeSchemaBase = z.object({
first_name: z.string().min(1).max(200),
last_name: z.string().min(1).max(200),
@@ -1869,9 +1869,9 @@ export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) =
// Phase 5 PR-1 carry-over (PR-2 enforcement): if vaxa_stod_eligible is set,
// require vaxa_stod_start. The end date is optional (some eligibility
// windows run open-ended until the maximum benefit period is reached).
// Birth-year age gate (the actual eligibility rule — born 2003-2007 for
// Birth-year age gate (the actual eligibility rule, born 2003-2007 for
// 2026) is checked at calculation-time by the engine, not here, because
// it depends on the payment year of each run — a 22-year-old at hire
// it depends on the payment year of each run: a 22-year-old at hire
// becomes 23 the next year and the rate switches without a row edit.
if (data.vaxa_stod_eligible && !data.vaxa_stod_start) {
ctx.addIssue({
@@ -1932,7 +1932,7 @@ export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((da
// already set can have vaxa_stod_eligible flipped on without also
// sending start in the body). What the schema CAN enforce:
// - If the body enables vaxa_stod AND clears vaxa_stod_start explicitly
// (sending null), reject — that would orphan the eligibility flag.
// (sending null), reject: that would orphan the eligibility flag.
// - If the body sets vaxa_stod_eligible=true AND vaxa_stod_start is
// present in the body but invalid relative to vaxa_stod_end, reject.
// The first case isn't currently expressible via .partial() (null != absent),
@@ -2007,7 +2007,7 @@ export const CreateSalaryRunSchema = z.object({
period_year: z.number().int().min(2020).max(2100),
period_month: z.number().int().min(1).max(12),
payment_date: isoDate,
voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z').default('A'),
voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z').default('A'),
notes: z.string().max(2000).optional(),
})
@@ -2071,7 +2071,7 @@ export const AbsenceRangeQuerySchema = z.object({
// ── Worked-hours per-day records (hourly employees) ─────────────────
//
// Drives base salary calculation for hourly (timanställd) employees:
// `baseSalary = hourly_rate × Σ hours`. Mirrors absence days deliberately —
// `baseSalary = hourly_rate × Σ hours`. Mirrors absence days deliberately:
// same calendar UX, half-day mixing with absence enforced by the 24h cap
// trigger. The calculator sums these per pay period at calculate time.
@@ -2081,8 +2081,8 @@ export const UpsertWorkedDaySchema = z
hours: z.number().positive().max(24).default(8),
notes: z.string().max(2000).optional(),
salary_run_employee_id: uuid.optional(),
// Optional shift window. Feeds the shift-premium engine — without explicit
// times, the engine assumes a default 08:00–17:00 day shift. Either both
// Optional shift window. Feeds the shift-premium engine: without explicit
// times, the engine assumes a default 08:00-17:00 day shift. Either both
// fields are provided or neither.
start_time: timeString.optional(),
end_time: timeString.optional(),
@@ -2168,7 +2168,7 @@ export const AcceptProposalSchema = z.object({
// Change the matched transaction on a pending match proposal without
// accepting it. Source tells us whether the user picked one of the AI's
// own alternatives, an AI-regenerated suggestion, or a manually-chosen
// transaction — kept on edit_diff for learning signal.
// transaction: kept on edit_diff for learning signal.
export const ChangeMatchProposalSchema = z.object({
version: z.number().int().nonnegative(),
matched_transaction_id: uuid,
@@ -2289,7 +2289,7 @@ export const UpdateShiftPremiumRuleSchema = z
*
* Each field is independently nullable. `null` clears a previously-set
* override; `undefined` leaves it unchanged. `reason` is required whenever
* any non-null override is being applied — the DB CHECK constraint
* any non-null override is being applied: the DB CHECK constraint
* enforces this at the storage layer too.
*/
// Upper bound on per-employee override values. 10 MSEK is well above any
@@ -2301,7 +2301,7 @@ export const SalaryEmployeeOverrideSchema = z
.object({
// Per-run monthly salary for this employee, editable while the run is a
// draft. 0 is allowed (an intentional nollkörning). This is NOT a review
// override — it sets the base the engine uses for this month only and does
// override: it sets the base the engine uses for this month only and does
// not require a reason. The route gates this field to `draft` status.
monthly_salary: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).optional(),
tax_withheld_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
@@ -2328,14 +2328,14 @@ export const SalaryEmployeeOverrideSchema = z
// ============================================================
// Dimensions PR6 — bulk retro-tagging workbench (appended at end
// Dimensions PR6: bulk retro-tagging workbench (appended at end
// of file by PR6 to avoid conflicts; keep new schemas below).
// ============================================================
/**
* Query filters for GET /api/dimensions/tagging/lines (the BulkTagWorkbench
* line browser). All filters optional; `limit` is a hard cap (default 200,
* max 500) — the route fetches limit+1 and reports `total_capped` instead of
* max 500): the route fetches limit+1 and reports `total_capped` instead of
* paginating (dimensions plan §3, v1 scope).
*/
export const DimensionTaggingLinesQuerySchema = z.object({
@@ -2351,7 +2351,7 @@ export const DimensionTaggingLinesQuerySchema = z.object({
/**
* '1' → include reversal pairs (annulled entries + their stornos). Excluded
* by default: a pair nets to zero in every dimension bucket when both sides
* carry the same tag, so retro-tagging it is a no-op — and showing it
* carry the same tag, so retro-tagging it is a no-op, and showing it
* invites tagging one side only, which skews project P&L.
*/
include_annulled: z.enum(['0', '1']).optional(),
@@ -2364,7 +2364,7 @@ export const DimensionTaggingLinesQuerySchema = z.object({
* to every listed line via the retag_line_dimensions RPC (the UI groups
* selected lines by their computed resulting map and issues one POST per
* distinct map). `dimensions` reuses THE bag schema so validation cannot
* drift from the engine/API layers; an empty bag is allowed — replace mode
* drift from the engine/API layers; an empty bag is allowed: replace mode
* uses it to clear phantom tags. `reason` mirrors the RPC's >= 3 chars CHECK.
*/
export const DimensionTaggingApplySchema = z.object({
@@ -34,7 +34,7 @@ async function insertOperation(params: {
return id
}
describe('operations-immutability.pg — terminal-status rows are immutable', () => {
describe('operations-immutability.pg: terminal-status rows are immutable', () => {
// Sanity: the regular running → succeeded transition (the path used by
// completeOperation in lib/api/v1/operations.ts) is NOT blocked. The
// trigger reads OLD.status; the legitimate UPDATE has OLD.status='running'
+1 -1
View File
@@ -85,7 +85,7 @@ describe('nextCursorFromPage', () => {
})
})
describe('decodeDefaultCursor — strict format validation', () => {
describe('decodeDefaultCursor: strict format validation', () => {
const validId = '8fd5b1f4-1234-1234-1234-1234567890ab'
const validTs = '2026-05-12T16:00:00Z'
@@ -1,11 +1,11 @@
/**
* Response-envelope contract test.
*
* Every v1 handler returns the canonical `{ data, meta }` envelope — `ok()` and
* Every v1 handler returns the canonical `{ data, meta }` envelope: `ok()` and
* `created()` wrap a single object, `paginated()` wraps an array, both stamping
* the shared `meta` block (see `lib/api/v1/response.ts`). The OpenAPI generator,
* however, derives each endpoint's documented body purely from its registered
* `response.success` Zod schema, and that schema is NOT validated at runtime —
* `response.success` Zod schema, and that schema is NOT validated at runtime:
* so nothing stops a route from declaring a shape the handler never sends.
*
* That is exactly what issue #794 found: every list endpoint declared a bare
@@ -16,7 +16,7 @@
*
* This test is the regression guard the issue asked for. It asserts EVERY
* JSON-returning endpoint declares the `{ data, meta }` envelope with the shared
* `ResponseMetaSchema` — so a new endpoint that forgets to wrap its schema
* `ResponseMetaSchema`: so a new endpoint that forgets to wrap its schema
* (list OR single) fails CI here instead of shipping a lying spec. Binary
* downloads (`response.contentType`) and 204 No Content endpoints
* (`NoBodyResponse`) carry no JSON body and are the only exemptions.
@@ -25,7 +25,7 @@
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { listEndpoints, ResponseMetaSchema, NoBodyResponse, listEnvelope, dataEnvelope } from '../registry'
// Side-effect import — every route file's registerEndpoint() runs at module
// Side-effect import: every route file's registerEndpoint() runs at module
// load time and populates the shared ENDPOINTS map.
import '../load-routes'
@@ -52,14 +52,14 @@ describe('v1 response envelope contract', () => {
const success = ep.response.success
if (!(success instanceof z.ZodObject)) {
violations.push(`${ctx}: response.success is not a { data, meta } object — wrap it with listEnvelope()/dataEnvelope() (or use NoBodyResponse for 204 / response.contentType for binary).`)
violations.push(`${ctx}: response.success is not a { data, meta } object: wrap it with listEnvelope()/dataEnvelope() (or use NoBodyResponse for 204 / response.contentType for binary).`)
continue
}
const shape = (success as z.ZodObject<z.ZodRawShape>).shape
const keys = Object.keys(shape).sort()
if (keys.length !== 2 || keys[0] !== 'data' || keys[1] !== 'meta') {
violations.push(`${ctx}: top-level keys must be [data, meta] — found [${keys.join(', ')}]. The handler returns { data, meta }; declare it with listEnvelope()/dataEnvelope().`)
violations.push(`${ctx}: top-level keys must be [data, meta]: found [${keys.join(', ')}]. The handler returns { data, meta }; declare it with listEnvelope()/dataEnvelope().`)
continue
}
@@ -79,7 +79,7 @@ describe('v1 response envelope contract', () => {
it('list endpoints expose data as an array (the paginated() envelope)', () => {
// Detect list endpoints structurally: their `data` is a Zod array. This is
// the half of the contract that maps onto paginated() specifically — guards
// the half of the contract that maps onto paginated() specifically: guards
// against a list endpoint drifting from `{ data: [...] }` back to a bare
// `{ <name>: [...] }` (which would drop the array out of `data` entirely).
const arrayDataEndpoints = endpoints.filter((ep) => {
+5 -5
View File
@@ -21,7 +21,7 @@
import { describe, expect, it } from 'vitest'
import { listEndpoints } from '../registry'
// Side-effect import — every route file's registerEndpoint() runs at
// Side-effect import: every route file's registerEndpoint() runs at
// module load time and populates the shared ENDPOINTS map.
import '../load-routes'
@@ -30,7 +30,7 @@ describe('v1 spec snapshot', () => {
it('matches the recorded endpoint count', () => {
// Update intentionally when adding/removing endpoints. The count is
// the cheapest first-line check — if it changes unexpectedly, CI
// the cheapest first-line check: if it changes unexpectedly, CI
// surfaces the surprise before reviewers have to spot it in the diff.
expect(endpoints.length).toMatchSnapshot('endpoint-count')
})
@@ -53,7 +53,7 @@ describe('v1 spec snapshot', () => {
// The /docs/api/reference pages and /llms-full.txt aggregator both
// assume every endpoint registers complete metadata. A registerEndpoint
// call that omits any of these fields would render a page with empty
// sections — surface the omission here instead.
// sections: surface the omission here instead.
for (const ep of endpoints) {
const ctx = `${ep.method} ${ep.path}`
expect(ep.summary, `${ctx}: missing summary`).toBeTruthy()
@@ -66,9 +66,9 @@ describe('v1 spec snapshot', () => {
// Defense-in-depth: every endpoint MUST explicitly declare its
// scope (or the literal sentinel `null` for genuinely public
// endpoints — e.g. /api/v1/health). `undefined` means the
// endpoints: e.g. /api/v1/health). `undefined` means the
// registerEndpoint call silently dropped the field, which would
// make the wrapper treat the route as unauthenticated. CC6.3 —
// make the wrapper treat the route as unauthenticated. CC6.3:
// surfacing the omission in CI prevents accidental public
// exposure of new endpoints.
expect(
+12 -12
View File
@@ -100,7 +100,7 @@ beforeEach(() => {
mockServiceClient.mockReturnValue(makeSupabaseStub(null))
})
describe('withApiV1 — auth', () => {
describe('withApiV1: auth', () => {
it('returns 401 when Authorization header is missing', async () => {
const handler = withApiV1('companies.list', async (_req, ctx) =>
ok({ ok: true }, { requestId: ctx.requestId }),
@@ -148,7 +148,7 @@ describe('withApiV1 — auth', () => {
})
})
describe('withApiV1 — scope', () => {
describe('withApiV1: scope', () => {
it('returns 403 INSUFFICIENT_SCOPE when the key lacks the required scope', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
@@ -197,7 +197,7 @@ describe('withApiV1 — scope', () => {
})
})
describe('withApiV1 — company membership', () => {
describe('withApiV1: company membership', () => {
it('returns 404 when the URL companyId is not a company the user belongs to', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
@@ -254,7 +254,7 @@ describe('withApiV1 — company membership', () => {
})
})
describe('withApiV1 — static (non-dynamic) route params', () => {
describe('withApiV1: static (non-dynamic) route params', () => {
// Regression for #781: GET /api/v1/companies is the only authenticated
// static route. Next.js 16 hands it `{ params: undefined }`. The wrapper
// must not null-deref on `params.params` after auth succeeds.
@@ -287,7 +287,7 @@ describe('withApiV1 — static (non-dynamic) route params', () => {
})
})
describe('withApiV1 — idempotency', () => {
describe('withApiV1: idempotency', () => {
it('replays a cached response when the idempotency key matches', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
@@ -357,7 +357,7 @@ describe('withApiV1 — idempotency', () => {
})
})
describe('withApiV1 — dry-run', () => {
describe('withApiV1: dry-run', () => {
it('threads dry_run=true from query string into context', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
@@ -429,7 +429,7 @@ describe('withApiV1 — dry-run', () => {
})
})
describe('withApiV1 — test mode', () => {
describe('withApiV1: test mode', () => {
it('blocks a test-key write on a non-simulatable endpoint (403 TEST_KEY_WRITE_BLOCKED)', async () => {
// No route modules are imported here, so the endpoint registry is empty →
// getEndpointByConcretePath returns undefined → the wrapper must refuse the
@@ -467,7 +467,7 @@ describe('withApiV1 — test mode', () => {
expect(handlerCalled).toBe(false)
})
it('allows a test-key READ unchanged — no forced dry-run, real data, X-Gnubok-Mode header', async () => {
it('allows a test-key READ unchanged: no forced dry-run, real data, X-Gnubok-Mode header', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
@@ -499,7 +499,7 @@ describe('withApiV1 — test mode', () => {
})
})
describe('withApiV1 — public endpoints', () => {
describe('withApiV1: public endpoints', () => {
it('invokes the handler without authentication for /api/v1/health', async () => {
let observedUserId: string | null = null
const handler = withApiV1('health.check', async (_req, ctx) => {
@@ -563,7 +563,7 @@ describe('withApiV1 — public endpoints', () => {
})
})
describe('withApiV1 — stable headers', () => {
describe('withApiV1: stable headers', () => {
it('always stamps X-Request-Id and Gnubok-Version', async () => {
const handler = withApiV1('health.check', async (_req, ctx) =>
ok({ status: 'ok' }, { requestId: ctx.requestId }),
@@ -575,7 +575,7 @@ describe('withApiV1 — stable headers', () => {
})
})
describe('truncateIp — privacy-preserving IP logging', () => {
describe('truncateIp: privacy-preserving IP logging', () => {
it('truncates IPv4 to /24', () => {
expect(truncateIp('203.0.113.42')).toBe('203.0.113.0/24')
})
@@ -605,5 +605,5 @@ describe('truncateIp — privacy-preserving IP logging', () => {
})
})
// Suppress unused-import warning — we re-export to keep the type chain visible.
// Suppress unused-import warning: we re-export to keep the type chain visible.
void mockStoreIdempotency
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* Canonical base URL for v1 surfaces.
*
* Use this — NOT `new URL(request.url).host` — when assembling URLs that go
* Use this, NOT `new URL(request.url).host`, when assembling URLs that go
* into discovery files, OpenAPI specs, or any response a 3rd-party agent
* caches. The inbound `Host` header is attacker-controlled at the edge; a
* spoofed value would otherwise poison agent discovery and redirect them
@@ -9,7 +9,7 @@
*
* Honours `NEXT_PUBLIC_APP_URL` (the same env var the rest of the codebase
* uses for absolute links). Falls back to `https://localhost:3000` for
* local development when the env var is unset — this is a deliberate
* local development when the env var is unset: this is a deliberate
* fail-closed default that any production deploy will override.
*/
+4 -4
View File
@@ -5,7 +5,7 @@
*
* 1. Every POST / PATCH / DELETE accepts `?dry_run=true` or `X-Dry-Run: true`.
* 2. A dry-run response returns 200 OK with `{ data: { dry_run: true, preview, ... } }`
* and the `X-Dry-Run: true` response header — NEVER the resource's
* and the `X-Dry-Run: true` response header: NEVER the resource's
* normal success status (201, 204, etc.). A caller that sees `200`
* with `X-Dry-Run` knows the write was NOT committed.
* 3. Commit by re-issuing the same request without `dry_run=true`, passing
@@ -18,7 +18,7 @@
* row, no journal lines. Useful for validating inputs and discovering
* conflicts (duplicate org_number, validation errors) before committing.
*
* - **Staged** (financial resources — invoices, journal entries, period
* - **Staged** (financial resources: invoices, journal entries, period
* ops, salary; later phases): the preview is the record PLUS a
* `staged_operation_id` from `pending_operations`, the `journal_lines`
* that would be posted, and the `voucher_number_assigned_on_commit`.
@@ -57,7 +57,7 @@ export interface DryRunPreviewStaged<T> extends DryRunPreviewBase<T> {
/**
* The voucher number that WOULD be assigned on commit. Present only
* when the write produces a posted journal entry. Voucher numbers are
* sequential, so this is a *projection* — the actual number could differ
* sequential, so this is a *projection*: the actual number could differ
* by one or two if another committer beat the agent to the next number.
*/
voucher_number_assigned_on_commit?: string
@@ -76,7 +76,7 @@ interface DryRunResponseOptions {
* Return a 200 OK dry-run response for a validation-only preview.
*
* Use for non-financial writes (customers, suppliers metadata, employee
* profiles, settings) where there's nothing to stage — the agent just wants
* profiles, settings) where there's nothing to stage: the agent just wants
* to know what would be written and whether validation passes.
*/
export function dryRunPreview<T>(preview: T, opts: DryRunResponseOptions): NextResponse {
+2 -2
View File
@@ -18,7 +18,7 @@
* }
*
* The first three fields exist on the legacy `getStructuredError` output.
* `recovery_hint`, `docs_url`, `valid_alternatives` are additive — derived from
* `recovery_hint`, `docs_url`, `valid_alternatives` are additive: derived from
* the registry's `remediation` block (when present) plus a per-code doc-URL
* derivation rule.
*/
@@ -85,7 +85,7 @@ async function rewriteEnvelope(
| null
if (!body?.error) {
// Should never happen — legacyErrorResponse always returns the envelope.
// Should never happen: legacyErrorResponse always returns the envelope.
const fallback: V1ErrorBody = {
error: {
code: 'INTERNAL_ERROR',
+2 -2
View File
@@ -7,7 +7,7 @@
* between 200 and 4000 tokens.
*
* Each endpoint declares its own allowlist of expandable keys. Unknown
* values produce a 400 VALIDATION_ERROR rather than being silently ignored —
* values produce a 400 VALIDATION_ERROR rather than being silently ignored:
* agents that typo expansions deserve a clear error.
*
* Usage:
@@ -15,7 +15,7 @@
* const ALLOWED = ['customer', 'items', 'payments'] as const
* type ExpandKey = (typeof ALLOWED)[number]
* const expand = parseExpand(url, ALLOWED)
* // returns: Set<ExpandKey> — empty if no ?expand param
* // returns: Set<ExpandKey>, empty if no ?expand param
*
* if (expand.has('customer')) { ... }
*/
+16 -16
View File
@@ -15,10 +15,10 @@
import '@/app/api/v1/health/route'
import '@/app/api/v1/companies/route'
// Phase 4 PR-2 (foundation) — async operations polling endpoint.
// Phase 4 PR-2 (foundation): async operations polling endpoint.
import '@/app/api/v1/operations/[id]/route'
// Phase 4 PR-2 — journal-entries primitives + voucher-gap-explanations.
// Phase 4 PR-2: journal-entries primitives + voucher-gap-explanations.
import '@/app/api/v1/companies/[companyId]/journal-entries/route'
import '@/app/api/v1/companies/[companyId]/journal-entries/[id]/route'
import '@/app/api/v1/companies/[companyId]/journal-entries/[id]/commit/route'
@@ -27,37 +27,37 @@ import '@/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route'
import '@/app/api/v1/companies/[companyId]/journal-entries/batch-create/route'
import '@/app/api/v1/companies/[companyId]/voucher-gap-explanations/route'
// Phase 4 PR-2 — compliance-check (Accounted's defensible edge).
// Phase 4 PR-2: compliance-check (Accounted's defensible edge).
import '@/app/api/v1/companies/[companyId]/compliance/check/route'
// Phase 4 PR-2 — fiscal-periods async ops (lock/close/year-end/opening-balances/currency-revaluation).
// Phase 4 PR-2: fiscal-periods async ops (lock/close/year-end/opening-balances/currency-revaluation).
import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route'
import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/close/route'
import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/year-end/route'
import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route'
import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route'
// Phase 4 PR-3 — Documents (multipart).
// Phase 4 PR-3: Documents (multipart).
import '@/app/api/v1/companies/[companyId]/documents/route'
import '@/app/api/v1/companies/[companyId]/documents/[id]/download/route'
import '@/app/api/v1/companies/[companyId]/documents/[id]/link/route'
// Phase 2 PR-A — invoice + customer reads.
// Phase 2 PR-A: invoice + customer reads.
import '@/app/api/v1/companies/[companyId]/invoices/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/route'
import '@/app/api/v1/companies/[companyId]/customers/route'
import '@/app/api/v1/companies/[companyId]/customers/[id]/route'
// Phase 2 PR-B-2b — invoice action verbs.
// Phase 2 PR-B-2b: invoice action verbs.
import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/credit/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/send/route'
import '@/app/api/v1/companies/[companyId]/invoices/bulk-create/route'
// Phase 2 PR-B-3 — invoice PDF + customer bulk-create.
// Phase 2 PR-B-3: invoice PDF + customer bulk-create.
import '@/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route'
import '@/app/api/v1/companies/[companyId]/customers/bulk-create/route'
// Phase 3 — transactions + reconciliation vertical.
// Phase 3: transactions + reconciliation vertical.
import '@/app/api/v1/companies/[companyId]/transactions/route'
import '@/app/api/v1/companies/[companyId]/transactions/[id]/route'
import '@/app/api/v1/companies/[companyId]/accounts/route'
@@ -71,7 +71,7 @@ import '@/app/api/v1/companies/[companyId]/transactions/batch-categorize/route'
import '@/app/api/v1/companies/[companyId]/reconciliation/bank/run/route'
import '@/app/api/v1/companies/[companyId]/reconciliation/bank/status/route'
// Phase 4 PR-1 — AP world: suppliers + supplier-invoices verticals.
// Phase 4 PR-1: AP world: suppliers + supplier-invoices verticals.
import '@/app/api/v1/companies/[companyId]/suppliers/route'
import '@/app/api/v1/companies/[companyId]/suppliers/[id]/route'
import '@/app/api/v1/companies/[companyId]/suppliers/bulk-create/route'
@@ -81,13 +81,13 @@ import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/approve/route'
import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route'
import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route'
// Phase 5 PR-1 — Payroll registers: employees + salary-runs CRUD.
// Phase 5 PR-1: Payroll registers: employees + salary-runs CRUD.
import '@/app/api/v1/companies/[companyId]/employees/route'
import '@/app/api/v1/companies/[companyId]/employees/[id]/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/route'
// Phase 5 PR-2 — Payroll lifecycle verbs. The /calculate orchestration was
// Phase 5 PR-2: Payroll lifecycle verbs. The /calculate orchestration was
// extracted into lib/salary/run-calculation.ts; the AGI orchestration into
// lib/salary/agi/generate-declaration.ts. Both the internal dashboard
// routes and these v1 routes call the same helpers.
@@ -97,7 +97,7 @@ import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/mark-paid/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/generate-agi/route'
// Phase 5 PR-3 — Reports + import async. All reports wrap existing
// Phase 5 PR-3: Reports + import async. All reports wrap existing
// lib/reports/* generators. Imports run inline today but record their
// progress on the `operations` table for consistent polling-shape. KPI,
// audit-trail, periodisk-sammanstallning, ne-bilaga, and ink2 are deferred
@@ -119,20 +119,20 @@ import '@/app/api/v1/companies/[companyId]/reports/sie-export/route'
import '@/app/api/v1/companies/[companyId]/imports/sie/route'
import '@/app/api/v1/companies/[companyId]/imports/bank/route'
// Phase 6 PR-1 — webhooks substrate.
// Phase 6 PR-1: webhooks substrate.
import '@/app/api/v1/companies/[companyId]/webhooks/route'
import '@/app/api/v1/companies/[companyId]/webhooks/[id]/route'
import '@/app/api/v1/companies/[companyId]/webhooks/[id]/test/route'
import '@/app/api/v1/companies/[companyId]/webhooks/[id]/deliveries/route'
import '@/app/api/v1/webhook-deliveries/[id]/retry/route'
// Phase 6 PR-3 — webhook secret rotation.
// Phase 6 PR-3: webhook secret rotation.
import '@/app/api/v1/companies/[companyId]/webhooks/[id]/rotate-secret/route'
// Inbox item stamp.
import '@/app/api/v1/companies/[companyId]/inbox-items/[id]/stamp/route'
// Dimensions PR2 — registry list + value creation (kostnadsställe/projekt).
// Dimensions PR2: registry list + value creation (kostnadsställe/projekt).
import '@/app/api/v1/companies/[companyId]/dimensions/route'
import '@/app/api/v1/companies/[companyId]/dimensions/[id]/values/route'
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Personnummer masking for v1 list/create responses.
*
* GDPR Art.5(1)(c) — data minimisation. A Swedish personnummer is a
* GDPR Art.5(1)(c): data minimisation. A Swedish personnummer is a
* national identifier; the list endpoint and create-response shape mask
* the last 4 digits (the gender + checksum) so a roster scan or a
* mistaken response log doesn't leak a natural-person identifier. The
+4 -4
View File
@@ -30,7 +30,7 @@ export interface OperationProgress {
/** Optional unit counters for percent calculation client-side. */
current?: number
total?: number
/** Free-form additional fields — kept under jsonb so additions don't migrate. */
/** Free-form additional fields: kept under jsonb so additions don't migrate. */
[k: string]: unknown
}
@@ -56,7 +56,7 @@ export interface OperationRow {
* and then resolves via `completeOperation` / `failOperation`.
*
* For true async dispatch (future cron worker), pass `status='queued'` and
* leave `started_at` null — the worker stamps it when it picks the row up.
* leave `started_at` null: the worker stamps it when it picks the row up.
*/
export async function startOperation(
supabase: SupabaseClient,
@@ -150,7 +150,7 @@ export async function failOperation(
/**
* Update progress on a running operation. Non-blocking: a write failure is
* logged but not raised — the work continues regardless.
* logged but not raised: the work continues regardless.
*/
export async function updateOperationProgress(
supabase: SupabaseClient,
@@ -168,7 +168,7 @@ export async function updateOperationProgress(
/**
* Read an operation row, scoped to the caller's company. Returns null when
* the id is not found (or belongs to another company — RLS already excludes
* the id is not found (or belongs to another company; RLS already excludes
* those, but the explicit `.eq('company_id')` keeps the contract clear).
*/
export async function getOperation(
+2 -2
View File
@@ -12,7 +12,7 @@
* to scrape that string to produce a structured error envelope.
* - More importantly, an INSERT that takes both `company_id` (from URL)
* and `fiscal_period_id` (from body) without verifying they belong
* together creates a broken-link state — the row persists with a
* together creates a broken-link state: the row persists with a
* pointer at another company's period. Downstream queries return
* garbage even though no data was leaked. See:
* - voucher_gap_explanations: detect_voucher_gaps would never match.
@@ -29,7 +29,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
* structured NOT_FOUND or VALIDATION_ERROR envelope as appropriate.
*
* Cross-period checks that need additional state (is_closed, locked_at)
* should still go through `checkPeriodLock` — this helper only answers the
* should still go through `checkPeriodLock`; this helper only answers the
* ownership question.
*/
export async function ownsFiscalPeriod(
+5 -5
View File
@@ -2,8 +2,8 @@
* Cursor-based pagination for v1 list endpoints.
*
* Cursors are opaque base64-JSON tokens encoding the keyset position. The
* default key is `(created_at, id)`, which is stable across concurrent writes
* — a row inserted after a cursor was minted appears in a later page, never
* default key is `(created_at, id)`, which is stable across concurrent writes:
* a row inserted after a cursor was minted appears in a later page, never
* mid-page. Endpoints that need a different sort key supply their own
* encoder/decoder pair.
*
@@ -29,7 +29,7 @@ export interface PaginationParams {
* { limit, cursor } pair with limit clamped to [1, MAX_LIMIT].
*
* Invalid `limit` (non-numeric, negative) falls back to DEFAULT_LIMIT rather
* than throwing — callers can always re-validate via Zod if strictness is
* than throwing: callers can always re-validate via Zod if strictness is
* needed.
*/
export function parsePaginationParams(url: URL): PaginationParams {
@@ -77,7 +77,7 @@ export function encodeDefaultCursor(row: { created_at: string; id: string } | nu
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
}
// Strict format guards — defence-in-depth against tampered cursors that
// Strict format guards: defence-in-depth against tampered cursors that
// could otherwise inject untyped strings into a query's `.gt(field, value)`.
// PostgREST would likely reject these, but validating here keeps the failure
// mode predictable (stale cursor → "start over") rather than 400-ing.
@@ -86,7 +86,7 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
* Decode a cursor produced by encodeDefaultCursor. Returns null when the
* input is missing or malformed — callers should treat null as "start from
* input is missing or malformed: callers should treat null as "start from
* the beginning" rather than 400-ing on a stale cursor.
*
* `ts` must parse as an ISO 8601 timestamp and `id` must be a UUID; anything
+16 -16
View File
@@ -8,7 +8,7 @@
*
* Three artefacts are derived from this registry:
* 1. The OpenAPI 3.1 spec at /api/v1/openapi.json (this file).
* 2. The MCP tool list (future — Phase 5).
* 2. The MCP tool list (future: Phase 5).
* 3. Runtime validators (Zod itself, used by handlers).
*
* Phase 1 ships a minimal Zod→JSON-Schema converter. Phase 2 will swap in
@@ -38,7 +38,7 @@ const ResponseAuditSchema = z.object({
* `lib/api/v1/response.ts`). List endpoints additionally populate
* `next_cursor`; it is absent on the final page. Writes may surface an
* `audit` block, and soft-degraded `?expand=` responses a `partial_expansions`
* list — both optional, so reads and lists omit them.
* list: both optional, so reads and lists omit them.
*/
export const ResponseMetaSchema = z.object({
request_id: z.string(),
@@ -54,7 +54,7 @@ export const ResponseMetaSchema = z.object({
* OpenAPI contract matches the runtime body.
*
* Previously each list endpoint declared a bare `{ <name>: [...] }` success
* object (e.g. `{ companies: [...] }`) that no handler ever emits — the
* object (e.g. `{ companies: [...] }`) that no handler ever emits: the
* generated spec advertised a shape the API never returns. See issue #781.
*/
export function listEnvelope<T extends ZodTypeAny>(item: T) {
@@ -69,7 +69,7 @@ export function listEnvelope<T extends ZodTypeAny>(item: T) {
* under `data` (via `ok()`), rather than a bare array under `data`.
*
* Most list endpoints return `{ data: [...] }` (use {@link listEnvelope}). A
* few — `accounts`, `fiscal-periods`, `webhooks` — deliberately wrap their
* few (`accounts`, `fiscal-periods`, `webhooks`) deliberately wrap their
* array in a named key (`{ data: { accounts: [...] } }`); their handlers and
* route tests lock that shape in. Declare those with
* `dataEnvelope(z.object({ <name>: z.array(Item) }))` so the OpenAPI contract
@@ -84,12 +84,12 @@ export function dataEnvelope<T extends ZodTypeAny>(data: T) {
/**
* Sentinel `response.success` for endpoints that return 204 No Content with an
* empty body — e.g. DELETE handlers calling `noContent()`. The OpenAPI
* empty body: e.g. DELETE handlers calling `noContent()`. The OpenAPI
* generator emits a bare `204` response (no schema) for these instead of a
* `200 { data, meta }`, and the envelope contract test exempts them.
*
* Identified by REFERENCE equality, so every 204 route MUST import this exact
* constant rather than declaring its own `z.object({})` — that is what lets the
* constant rather than declaring its own `z.object({})`: that is what lets the
* generator and the contract test recognise the "no body" intent.
*/
export const NoBodyResponse = z.object({})
@@ -110,10 +110,10 @@ export interface EndpointDefinition {
/** Longer prose for the docs and the registered MCP tool description. */
description: string
/** Positive trigger — when should an agent reach for this endpoint? */
/** Positive trigger: when should an agent reach for this endpoint? */
useWhen: string
/** Negative trigger — what looks similar but isn't this. */
/** Negative trigger: what looks similar but isn't this. */
doNotUseFor: string
/** Common pitfalls. Bullet-list style; agents see this in tool docs. */
@@ -128,7 +128,7 @@ export interface EndpointDefinition {
/** Required scope; null for public endpoints. */
scope: ApiKeyScope | null
/** Action risk — informs whether the agent should confirm before calling. */
/** Action risk: informs whether the agent should confirm before calling. */
risk: ActionRisk
/** True for GET requests and well-known idempotent writes. */
@@ -186,14 +186,14 @@ const ENDPOINTS = new Map<string, EndpointDefinition>()
* ...
* })
*
* The wrapper does not depend on registration — scope resolution lives in
* The wrapper does not depend on registration: scope resolution lives in
* `lib/auth/scopes.ts` so a missing register() call only affects docs, not
* runtime auth. CI test asserts every wrapped route appears in the registry.
*/
export function registerEndpoint(def: EndpointDefinition): void {
const key = `${def.method} ${def.path}`
if (ENDPOINTS.has(key)) {
// Duplicate registration is a bug — log loudly. Throwing during a route
// Duplicate registration is a bug: log loudly. Throwing during a route
// module's top-level eval would break unrelated routes; warn instead.
// eslint-disable-next-line no-console
console.warn(`[api/v1/registry] duplicate endpoint registration: ${key}`)
@@ -213,7 +213,7 @@ export function getEndpoint(method: HttpMethod, path: string): EndpointDefinitio
* Resolve the registered endpoint for a CONCRETE request path (e.g.
* `/api/v1/companies/abc/customers`) by matching it against the registered
* `:param` patterns. Used by the wrapper to read an endpoint's `dryRunSupported`
* flag at request time — the route module being served has already run its
* flag at request time: the route module being served has already run its
* `registerEndpoint()` call, so its pattern is present. Returns undefined when
* no pattern matches (the wrapper treats that as "cannot be simulated").
*/
@@ -316,7 +316,7 @@ function zodToJsonSchema(schema: ZodTypeAny): JsonSchema {
case 'union':
case 'ZodUnion': {
// Best-effort: emit a oneOf with each member converted. No top-level
// `type` constraint — the individual branches carry their own types
// `type` constraint: the individual branches carry their own types
// (valid JSON Schema for a union).
const options = (def as { options?: ZodTypeAny[] }).options ?? []
return { oneOf: options.map(zodToJsonSchema) } as unknown as JsonSchema
@@ -355,7 +355,7 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec {
? { [def.response.contentType]: { schema: { type: 'string', format: 'binary' } } }
: { 'application/json': { schema: zodToJsonSchema(def.response.success) } }
// 204 No Content endpoints (DELETEs returning noContent()) carry no body —
// 204 No Content endpoints (DELETEs returning noContent()) carry no body:
// emit a bare 204 instead of a 200 { data, meta } so the spec stops
// advertising a response shape these handlers never send.
const successResponse = def.response.success === NoBodyResponse
@@ -398,7 +398,7 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec {
title: 'Accounted API',
version: API_V1_VERSION,
description:
'Public REST API for Accounted — Swedish double-entry bookkeeping. ' +
'Public REST API for Accounted: Swedish double-entry bookkeeping. ' +
'Every write supports dry-run via `?dry_run=true`. Every request must include ' +
'`Authorization: Bearer gnubok_sk_...`. See /docs/api for the cookbook.',
},
@@ -418,7 +418,7 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec {
}
/**
* Test-only escape hatch. Clears the registry — used in unit tests so a test
* Test-only escape hatch. Clears the registry: used in unit tests so a test
* that registers a fake endpoint doesn't leak into the next test.
*/
export function _resetRegistryForTests(): void {
+6 -6
View File
@@ -8,17 +8,17 @@
* the auth wrapper's stamping cover them. Public routes are an exception
* because they're plain `NextResponse.json/text` returns with caching.
*
* X-Content-Type-Options: nosniff — block MIME sniffing on text/json
* Referrer-Policy: strict-origin... — limit referrer leakage if a link is
* X-Content-Type-Options: nosniff : block MIME sniffing on text/json
* Referrer-Policy: strict-origin...: limit referrer leakage if a link is
* embedded somewhere unexpected
* X-Frame-Options: DENY — discovery surfaces should never
* X-Frame-Options: DENY : discovery surfaces should never
* legitimately render in a frame
*/
/**
* Headers applied to BOTH public discovery routes and authenticated v1
* responses. Includes CSP, HSTS, and frame/sniff/referrer protections, but
* NOT X-Robots-Tag — discovery routes (llms.txt, skills index, OpenAPI)
* NOT X-Robots-Tag: discovery routes (llms.txt, skills index, OpenAPI)
* exist to be crawled by AI agents; authenticated routes get an additional
* X-Robots-Tag at the wrapper level via WRAPPED_RESPONSE_NOAI_HEADERS.
*/
@@ -26,11 +26,11 @@ export const PUBLIC_SECURITY_HEADERS: Record<string, string> = {
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'X-Frame-Options': 'DENY',
// Discovery routes return JSON or plain text — no script, style, image, or
// Discovery routes return JSON or plain text: no script, style, image, or
// form contexts. `default-src 'none'` is the strictest possible CSP and
// costs nothing here.
'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'",
// HSTS — every Accounted deployment is HTTPS-only. 1 year is the standard
// HSTS: every Accounted deployment is HTTPS-only. 1 year is the standard
// production value; includeSubDomains because the apex serves everything.
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
}
+14 -14
View File
@@ -13,7 +13,7 @@
* the token when one is supplied.
* 4. When the URL contains `companyId`, verifies the API key's user has
* access to that company via `company_members`. Multi-company keys are
* supported transparently — the URL is the source of truth.
* supported transparently: the URL is the source of truth.
* 5. Resolves `Idempotency-Key` (header) and replays cached responses.
* 6. Resolves the dry-run flag (`?dry_run=true` query OR `X-Dry-Run` header).
* 7. Invokes the handler with a typed RouteContext.
@@ -46,7 +46,7 @@ import {
// Per CLAUDE.md: any route that emits events via eventBus must call
// ensureInitialized() at module level to wire extension event handlers
// (email, cloud-backup, push-notifications, etc.). Calling it here in the
// wrapper guarantees every v1 route gets the init at import time — a single
// wrapper guarantees every v1 route gets the init at import time: a single
// source of truth so future routes can't forget. The function itself is
// idempotent (guarded by a module-level boolean).
ensureInitialized()
@@ -68,7 +68,7 @@ const DRY_RUN_HEADER = 'X-Dry-Run'
const REQUIRES_IDEMPOTENCY = new Set(['POST', 'PATCH', 'DELETE'])
export interface ApiV1Context {
/** Stable id for this HTTP request — appears in logs, error envelope, X-Request-Id. */
/** Stable id for this HTTP request: appears in logs, error envelope, X-Request-Id. */
requestId: string
/** Logger pre-bound with { requestId, userId, companyId?, operation, apiKeyId? }. */
log: Logger
@@ -81,7 +81,7 @@ export interface ApiV1Context {
/** Scopes granted to the calling key. */
scopes: ApiKeyScope[]
/**
* test|live. Test keys are simulation-only — the wrapper forces `dryRun` on
* test|live. Test keys are simulation-only: the wrapper forces `dryRun` on
* for every write, so handlers never need to special-case `mode`; they just
* honor `dryRun` as usual.
*/
@@ -104,7 +104,7 @@ interface ApiV1Options {
/** Override the required scope (e.g. for ad-hoc endpoints not in the catalogue). */
requireScope?: ApiKeyScope
/**
* When true, idempotency is enforced — POST/PATCH/DELETE without an
* When true, idempotency is enforced: POST/PATCH/DELETE without an
* `Idempotency-Key` header return 400. Default false; can be flipped on
* per-route once the integrator audience is sophisticated enough.
*/
@@ -130,7 +130,7 @@ function generateRequestId(): string {
* enforced (no service-role privilege escalation) so even an accidental DB
* call from a public handler is constrained to anon-accessible rows.
*
* Fails closed at first-call if the required env vars are missing — better
* Fails closed at first-call if the required env vars are missing: better
* to surface the misconfiguration on the first request than silently 500
* deeper in the handler.
*/
@@ -185,8 +185,8 @@ function extractForensicContext(request: Request, log: Logger): { ip: string | u
if (raw && !ip) {
// x-forwarded-for / x-real-ip carried a non-empty payload we couldn't parse.
// Surface as a warn so spoofed / unexpected proxy values are visible in
// security monitoring instead of silently dropped. Never log the raw value
// — that would defeat the truncation step.
// security monitoring instead of silently dropped. Never log the raw value:
// that would defeat the truncation step.
log.warn('unparseable forwarded-for header dropped', { headerLength: raw.length })
}
const userAgent = request.headers.get('user-agent') ?? undefined
@@ -202,7 +202,7 @@ function isDryRun(request: Request, url: URL): boolean {
async function readBodyForHash(request: Request): Promise<{ body: unknown; cloned: Request }> {
// We need the body to hash it, but the handler also needs it. Read from a
// CLONE for the hash and pass the original through to the handler — that
// CLONE for the hash and pass the original through to the handler: that
// way the handler's `await request.json()` still works regardless of how
// the runtime implements stream teeing.
const reader = request.clone()
@@ -250,7 +250,7 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
// 2. Public endpoints: invoke handler with an anon context. If a Bearer
// token IS supplied we opportunistically validate it so rate-limiting
// and key attribution are applied — but a missing or invalid token
// and key attribution are applied, but a missing or invalid token
// does NOT block the request (the route is, by definition, public).
// Falling back to the anon client when unauthenticated keeps the
// least-privilege guarantee: an accidental DB call from a public
@@ -283,7 +283,7 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
supabase: createServiceClientNoCookies(),
}
}
// Invalid token on a public route is silently downgraded to anon —
// Invalid token on a public route is silently downgraded to anon:
// do not surface 401 since the route doesn't require auth at all.
}
const response = await handler(request, publicCtx, params)
@@ -326,7 +326,7 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
// 5. Resolve URL companyId and verify access.
//
// Next.js 16 invokes a route handler with `{ params: undefined }` for a
// STATIC route (no `[segment]` in the path) — see app-route module.js
// STATIC route (no `[segment]` in the path): see app-route module.js
// `handlerContext = { params: context.params ? ... : undefined }`. The
// only authenticated static route on this surface is `/api/v1/companies`,
// so awaiting `params.params` blindly null-derefs there (`undefined` has
@@ -373,7 +373,7 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
// Test keys are simulation-only: every write is forced to dry-run so
// nothing persists (the credential bakes in `?dry_run=true`). A mutating
// endpoint that can't be simulated (dryRunSupported=false, or unregistered)
// would otherwise write for real — block it outright so a test key can
// would otherwise write for real: block it outright so a test key can
// never touch real data. Reads pass through unchanged (real data, no write).
let forceDryRun = false
if (auth.mode === 'test' && isMutation) {
@@ -496,7 +496,7 @@ function stampHeaders(response: Response, requestId: string): Response {
if (!response.headers.get(API_V1_VERSION_HEADER)) {
response.headers.set(API_V1_VERSION_HEADER, API_V1_VERSION)
}
// Apply security headers to every wrapped v1 response — same set as the
// Apply security headers to every wrapped v1 response: same set as the
// public discovery routes PLUS X-Robots-Tag noai so authenticated payloads
// are excluded from AI training sets (Claude, ChatGPT, Perplexity, Google
// -Extended respect this; others won't).
+3 -3
View File
@@ -32,11 +32,11 @@ import { createLogger, type Logger } from '@/lib/logger'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
export interface RouteContext {
/** Stable id for this HTTP request — appears in logs, error envelope, X-Request-Id header. */
/** Stable id for this HTTP request: appears in logs, error envelope, X-Request-Id header. */
requestId: string
/** Logger pre-bound with { requestId, userId, companyId, operation }. */
log: Logger
/** Authenticated user. Always present — wrapper short-circuits with 401 otherwise. */
/** Authenticated user. Always present: wrapper short-circuits with 401 otherwise. */
user: User
/** Authenticated Supabase client (request-scoped, RLS active). */
supabase: SupabaseClient
@@ -68,7 +68,7 @@ interface RouteContextOptions {
}
// Next.js 16 always passes a `{ params: Promise<...> }` second arg to route
// handlers — including on non-dynamic routes, where it's `Promise<{}>`. The
// handlers: including on non-dynamic routes, where it's `Promise<{}>`. The
// generic defaults to that empty shape so static routes type-check without
// having to declare any params at the call site.
// eslint-disable-next-line @typescript-eslint/no-empty-object-type