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:
co-authored by
Claude Sonnet 5
parent
a5154ee884
commit
ec27228a8e
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user