feat(mcp): byte-exact SIE upload path + brevity and memory-first onboarding (#1954)
From the fourth E2E run: the agent correctly refused to reproduce a 104 KB SIE file token by token (silent mid-verifikat truncation) and dead-ended to the web wizard, and its replies were walls of compliance prose. 1. gnubok_create_sie_upload: signed same-origin upload URL (reuses the pending-document infra; .se/.sie/.si only, 50 MB HTTP cap). gnubok_sie_preflight and gnubok_import_sie accept upload_id as the byte-exact source, plus optional sha256 (hex of the raw bytes) verified on the upload_id/base64 paths so truncation is DETECTED, never silent. Inline content above 120k chars is refused with a pointer to the upload flow. Scope bookkeeping:write (same intent as import_sie). 2. Skill: brevity rule (max ~8 short lines per reply, one warning per step, no legal essays), memory-first rule (check what is already known before asking the opening questions), the upload-first SIE step, and gnubok_explain_voucher_gap after import for skipped voucher numbers. 3. CONNECTORS.md starter prompt rewritten memory-first so it stays copy-paste ready without the user's own data in it. Plugin v1.2.2. tools/list ceiling 62K to 62.4K documented in the bench. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
"name": "accounted",
|
||||
"displayName": "Accounted",
|
||||
"description": "Official Accounted plugin: Swedish double-entry bookkeeping flows for Claude. Connects your ledger over MCP and ships short workflow skills (daily bookkeeping, health check, month close, VAT, payroll, year-end) that work from the company's live data and load Swedish accounting knowledge from the product when needed. Every write is staged for your approval; nothing is booked on its own.",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"author": {
|
||||
"name": "Accounted (erp-mafia)"
|
||||
},
|
||||
|
||||
@@ -14,14 +14,9 @@ One-click add on claude.ai (opens the Add custom connector dialog prefilled; the
|
||||
https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Accounted&connectorUrl=https%3A%2F%2Fapp.accounted.se%2Fapi%2Fextensions%2Fext%2Fmcp-server%2Fmcp%3Ftool_namespace%3Daccounted
|
||||
```
|
||||
|
||||
Pair the link with a starter prompt the user pastes as their first message. The short form works because the server-side onboarding skill carries the whole flow; the long form pre-answers the three opening questions and saves a round-trip:
|
||||
Pair the link with a starter prompt the user pastes as their first message. It stays copy-paste ready for everyone because it points the agent at what it already knows (Claude memory, earlier chats) instead of containing the user's own data; the server-side onboarding skill carries the rest of the flow:
|
||||
|
||||
> Sätt upp mitt företag i Accounted.
|
||||
|
||||
> Sätt upp mitt företag i Accounted.
|
||||
> Organisationsnummer: `<orgnr>`
|
||||
> Tidigare bokföringssystem: `<t.ex. Fortnox, eller inget>`
|
||||
> Bank: `<t.ex. Swedbank>`
|
||||
> Sätt upp mitt företag i Accounted. Utgå från det du redan vet om mig och mitt bolag (organisationsnummer, bank, tidigare bokföringssystem) och fråga bara efter det som saknas. Håll det kort.
|
||||
|
||||
## How the connection works
|
||||
|
||||
|
||||
@@ -230,9 +230,13 @@ describe('tools/list payload size guard', () => {
|
||||
// the scan-before-import step the skill instructs for shared SIE
|
||||
// files, so default-catalog for the same Claude.ai reason; ~390
|
||||
// tokens (schema carries the mappings-passthrough contract).
|
||||
// * 62K to 62.4K with gnubok_create_sie_upload + upload_id/sha256 on the
|
||||
// two SIE tools: the byte-exact upload path after a real 104 KB file
|
||||
// dead-ended in chat (a model cannot reproduce 30k tokens verbatim
|
||||
// without silent-truncation risk); skill-instructed, so default catalog.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(62_000)
|
||||
expect(approxTokens).toBeLessThan(62_400)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -184,4 +184,45 @@ describe('gnubok_sie_preflight', () => {
|
||||
it('rejects a call with neither content field', async () => {
|
||||
await expect(run({})).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
|
||||
})
|
||||
|
||||
it('refuses oversized inline content instead of risking silent mid-verifikat truncation', async () => {
|
||||
const huge = VALID_SIE + '\n' + '#KONTO 9999 "x"\n'.repeat(10_000)
|
||||
await expect(run({ file_content: huge })).rejects.toMatchObject({
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: expect.stringContaining('gnubok_create_sie_upload'),
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies sha256 on the base64 path and rejects a mismatch as truncation', async () => {
|
||||
const bytes = Buffer.from(VALID_SIE, 'utf8')
|
||||
const { createHash } = await import('node:crypto')
|
||||
const good = createHash('sha256').update(bytes).digest('hex')
|
||||
|
||||
const ok = await run({ file_content_base64: bytes.toString('base64'), sha256: good })
|
||||
expect(ok.verdict).toBe('ok')
|
||||
|
||||
await expect(
|
||||
run({ file_content_base64: bytes.toString('base64'), sha256: 'a'.repeat(64) })
|
||||
).rejects.toMatchObject({
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: expect.stringContaining('sha256 mismatch'),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_create_sie_upload', () => {
|
||||
const uploadTool = tools.find((t) => t.name === 'gnubok_create_sie_upload')!
|
||||
|
||||
it('is registered with the filename-only contract', () => {
|
||||
expect(uploadTool).toBeDefined()
|
||||
expect(
|
||||
(uploadTool.inputSchema as { required: string[] }).required
|
||||
).toEqual(['filename'])
|
||||
})
|
||||
|
||||
it('rejects a filename that is not a SIE file', async () => {
|
||||
await expect(
|
||||
uploadTool.execute({ filename: 'export.xlsx' }, COMPANY_ID, 'user-1', {} as never)
|
||||
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -257,8 +257,11 @@ import {
|
||||
createPendingDocumentUpload,
|
||||
uploadDocument,
|
||||
MAX_DOCUMENT_SIZE,
|
||||
buildPendingDocumentStoragePath,
|
||||
DOCUMENTS_BUCKET,
|
||||
} from '@/lib/core/documents/document-service'
|
||||
import { toSameOriginStorageUrl } from '@/lib/core/documents/storage-proxy'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { extractInvoiceFields, ExtractionSchema as InvoiceExtractionSchema, AgentExtractionSchema } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
|
||||
import { mirrorExtractionToDocument } from '@/extensions/general/invoice-inbox/lib/mirror-extraction'
|
||||
// Skatteverket filing tools (PR5). Cross-extension lib import, same sanctioned
|
||||
@@ -1445,22 +1448,101 @@ export function isDefaultCatalogTool(tool: { catalogVisibility?: 'default' | 'se
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve SIE file content from tool args: plain text (the model read the
|
||||
* attachment) or base64 (exact bytes, e.g. from a code-execution sandbox).
|
||||
* The base64 path runs the same encoding detection as the HTTP upload route,
|
||||
* so CP437 exports keep their åäö instead of arriving pre-mangled through a
|
||||
* host's UTF-8 read. Returns null when neither field is usable.
|
||||
* Inline SIE content above this length is refused: a model reproducing tens
|
||||
* of thousands of tokens verbatim WILL eventually truncate mid-#VER, and a
|
||||
* truncated file that still parses imports silently incomplete bookkeeping.
|
||||
* Larger files go through gnubok_create_sie_upload (raw bytes, no model in
|
||||
* the path). ~120k chars ≈ a few thousand verifikat rows.
|
||||
*/
|
||||
async function decodeSieToolContent(args: Record<string, unknown>): Promise<string | null> {
|
||||
if (typeof args.file_content === 'string' && args.file_content.length > 0) {
|
||||
return args.file_content
|
||||
const MAX_INLINE_SIE_CHARS = 120_000
|
||||
|
||||
/**
|
||||
* Resolve SIE file content from tool args, three sources in priority order:
|
||||
*
|
||||
* - upload_id: raw bytes PUT to a gnubok_create_sie_upload URL. The only
|
||||
* path with no model in the loop; required for large files.
|
||||
* - file_content_base64: exact bytes base64-encoded (e.g. from a
|
||||
* code-execution sandbox).
|
||||
* - file_content: plain text as the model read the attachment.
|
||||
*
|
||||
* Byte paths run the same encoding detection as the HTTP upload route, so
|
||||
* CP437 exports keep their åäö. An optional sha256 (hex, of the RAW BYTES)
|
||||
* is verified on the two byte-exact paths and proves the content was not
|
||||
* truncated or altered in transit; it cannot be checked for plain text
|
||||
* (the model's read may legitimately differ from the file's bytes).
|
||||
* Returns null when no source is usable.
|
||||
*/
|
||||
async function resolveSieToolContent(
|
||||
args: Record<string, unknown>,
|
||||
companyId: string,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
const sha256 = typeof args.sha256 === 'string' ? args.sha256.trim().toLowerCase() : null
|
||||
const verifyBytes = (buffer: Buffer, source: string) => {
|
||||
if (!sha256) return
|
||||
const actual = createHash('sha256').update(buffer).digest('hex')
|
||||
if (actual !== sha256) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`sha256 mismatch on ${source}: expected ${sha256}, got ${actual}. The content was truncated or altered; re-send it.`
|
||||
),
|
||||
{ code: 'VALIDATION_ERROR' }
|
||||
)
|
||||
}
|
||||
}
|
||||
if (typeof args.file_content_base64 === 'string' && args.file_content_base64.length > 0) {
|
||||
const { detectEncoding, decodeBuffer } = await import('@/lib/import/sie-parser')
|
||||
const buffer = Buffer.from(args.file_content_base64, 'base64')
|
||||
|
||||
const { detectEncoding, decodeBuffer } = await import('@/lib/import/sie-parser')
|
||||
|
||||
if (typeof args.upload_id === 'string' && args.upload_id.length > 0) {
|
||||
const fileName = typeof args.filename === 'string' ? args.filename : ''
|
||||
if (!fileName) {
|
||||
throw Object.assign(new Error('filename is required together with upload_id'), {
|
||||
code: 'VALIDATION_ERROR',
|
||||
})
|
||||
}
|
||||
const service = createServiceClientNoCookies()
|
||||
const pendingPath = buildPendingDocumentStoragePath(companyId, userId, args.upload_id, fileName)
|
||||
const { data, error } = await service.storage.from(DOCUMENTS_BUCKET).download(pendingPath)
|
||||
if (error || !data) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
'No uploaded file found for this upload_id. PUT the raw file bytes to the upload_url from gnubok_create_sie_upload first (same upload_id and filename).'
|
||||
),
|
||||
{ code: 'NOT_FOUND' }
|
||||
)
|
||||
}
|
||||
const buffer = Buffer.from(await data.arrayBuffer())
|
||||
verifyBytes(buffer, 'the uploaded file')
|
||||
const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
|
||||
return decodeBuffer(arrayBuffer, detectEncoding(arrayBuffer))
|
||||
}
|
||||
|
||||
if (typeof args.file_content_base64 === 'string' && args.file_content_base64.length > 0) {
|
||||
if (args.file_content_base64.length > MAX_INLINE_SIE_CHARS) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
'File too large to pass inline safely. Use gnubok_create_sie_upload, PUT the raw bytes to its upload_url, and pass the upload_id here instead.'
|
||||
),
|
||||
{ code: 'VALIDATION_ERROR' }
|
||||
)
|
||||
}
|
||||
const buffer = Buffer.from(args.file_content_base64, 'base64')
|
||||
verifyBytes(buffer, 'file_content_base64')
|
||||
const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
|
||||
return decodeBuffer(arrayBuffer, detectEncoding(arrayBuffer))
|
||||
}
|
||||
|
||||
if (typeof args.file_content === 'string' && args.file_content.length > 0) {
|
||||
if (args.file_content.length > MAX_INLINE_SIE_CHARS) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
'File too large to pass inline safely (silent mid-verifikat truncation risk). Use gnubok_create_sie_upload, PUT the raw bytes to its upload_url, and pass the upload_id here instead.'
|
||||
),
|
||||
{ code: 'VALIDATION_ERROR' }
|
||||
)
|
||||
}
|
||||
return args.file_content
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -16170,6 +16252,60 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_create_sie_upload',
|
||||
title: 'Create SIE Upload',
|
||||
description:
|
||||
'Short-lived URL for a model-free SIE upload: PUT the raw .se/.sie bytes (max 50 MB) to upload_url, then pass upload_id (+ same filename) to gnubok_sie_preflight and gnubok_import_sie. Required for files too large to pass inline; add sha256 of the bytes there to prove integrity.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
filename: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 255,
|
||||
description: 'File name with extension, for example "export.se"',
|
||||
},
|
||||
},
|
||||
required: ['filename'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
upload_id: { type: 'string' },
|
||||
upload_url: { type: 'string' },
|
||||
expires_at: { type: 'string' },
|
||||
},
|
||||
required: ['upload_id', 'upload_url', 'expires_at'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const fileName = args.filename as string
|
||||
const lower = fileName.toLowerCase()
|
||||
if (!lower.endsWith('.se') && !lower.endsWith('.sie') && !lower.endsWith('.si')) {
|
||||
throw Object.assign(new Error('filename must end in .se, .sie or .si'), {
|
||||
code: 'VALIDATION_ERROR',
|
||||
})
|
||||
}
|
||||
const uploadId = crypto.randomUUID()
|
||||
const reservation = await createPendingDocumentUpload(supabase, companyId, userId, uploadId, fileName)
|
||||
// Served from the app origin: agent sandboxes only reach the MCP host,
|
||||
// not <project>.supabase.co. See storage-proxy.ts.
|
||||
return {
|
||||
upload_id: reservation.uploadId,
|
||||
upload_url: toSameOriginStorageUrl(reservation.signedUrl),
|
||||
expires_at: reservation.expiresAt,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_sie_preflight',
|
||||
title: 'SIE Preflight Scan',
|
||||
@@ -16179,8 +16315,10 @@ export const tools: McpTool[] = [
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
file_content: { type: 'string', description: 'Full SIE file contents as text' },
|
||||
file_content_base64: { type: 'string', description: 'Exact file bytes base64-encoded (preferred when available: preserves CP437 åäö)' },
|
||||
file_content: { type: 'string', description: 'Full SIE file contents as text (small files only)' },
|
||||
file_content_base64: { type: 'string', description: 'Exact file bytes base64-encoded (preserves CP437 åäö)' },
|
||||
upload_id: { type: 'string', description: 'From gnubok_create_sie_upload after PUTting the bytes; the only safe path for large files' },
|
||||
sha256: { type: 'string', description: 'Hex sha256 of the raw file bytes; verified on upload_id/base64 paths to prove nothing was truncated' },
|
||||
filename: { type: 'string' },
|
||||
},
|
||||
required: ['filename'],
|
||||
@@ -16205,11 +16343,13 @@ export const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, _userId, supabase) {
|
||||
const content = await decodeSieToolContent(args)
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const content = await resolveSieToolContent(args, companyId, userId)
|
||||
if (!content) {
|
||||
throw Object.assign(
|
||||
new Error('Provide the SIE file as file_content (text) or file_content_base64 (exact bytes).'),
|
||||
new Error(
|
||||
'Provide the SIE file as upload_id (from gnubok_create_sie_upload), file_content_base64, or file_content.'
|
||||
),
|
||||
{ code: 'VALIDATION_ERROR' }
|
||||
)
|
||||
}
|
||||
@@ -16341,8 +16481,10 @@ export const tools: McpTool[] = [
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
file_content: { type: 'string', description: 'Full SIE file contents' },
|
||||
file_content: { type: 'string', description: 'Full SIE file contents (small files only)' },
|
||||
file_content_base64: { type: 'string', description: 'Exact file bytes base64-encoded; alternative to file_content (preserves CP437 åäö)' },
|
||||
upload_id: { type: 'string', description: 'From gnubok_create_sie_upload after PUTting the bytes; the only safe path for large files' },
|
||||
sha256: { type: 'string', description: 'Hex sha256 of the raw file bytes; verified on upload_id/base64 paths' },
|
||||
filename: { type: 'string', description: 'Original filename' },
|
||||
mappings: {
|
||||
type: 'array',
|
||||
@@ -16363,12 +16505,12 @@ export const tools: McpTool[] = [
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
// Decoded once here; the staged params carry the decoded text so
|
||||
// commitImportSie re-parses exactly what was previewed.
|
||||
const fileContent = await decodeSieToolContent(args)
|
||||
const fileContent = await resolveSieToolContent(args, companyId, userId)
|
||||
const filename = args.filename as string
|
||||
const mappings = args.mappings as unknown[] | undefined
|
||||
|
||||
if (!fileContent || !filename || !Array.isArray(mappings)) {
|
||||
throw new Error('file_content (or file_content_base64), filename, and mappings are required')
|
||||
throw new Error('file content (upload_id, file_content_base64 or file_content), filename, and mappings are required')
|
||||
}
|
||||
|
||||
// Parse + validate at stage time so the approver sees real content (which
|
||||
|
||||
@@ -16,6 +16,17 @@ continue straight into import or categorization. Staged writes still go
|
||||
through their normal approval, but that approval IS the conversation, not an
|
||||
extra pause around it.
|
||||
|
||||
**Brevity rule: this is onboarding, not a course.** Keep every reply under
|
||||
~8 short lines. At most ONE warning per step, one line, and only when it
|
||||
changes what the user should do right now. No legal essays, no deadline
|
||||
tables, no cross-checks the user did not ask about (bolagsstämma dates,
|
||||
EU-moms edge cases, K-regelverk). The user can always ask for depth; the
|
||||
flow must never make them scroll past it.
|
||||
|
||||
**Memory first.** Before asking the opening questions, check what you
|
||||
already know about the user (memory, earlier conversation): orgnr, company
|
||||
name, bank, previous system. Ask only for what is genuinely missing.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Sätt upp bokföring för mitt AB / min enskilda firma"
|
||||
@@ -94,19 +105,28 @@ reaches far enough back anyway.
|
||||
Exportera data → SIE, **Björn Lundén / Briox / Wint** under Export.
|
||||
Every Swedish system exports SIE4 (.se/.sie); ask them to attach the
|
||||
file here in the chat.
|
||||
2. When the file arrives, call \`gnubok_sie_preflight\` with its content
|
||||
(\`file_content\` as read, or \`file_content_base64\` when exact bytes are
|
||||
available: that preserves åäö in CP437 exports). Summarize the scan:
|
||||
source system, fiscal years, verifikat count, balance status, org-number
|
||||
match, warnings. This is the "does it look correct" moment: surface
|
||||
problems BEFORE anything is written.
|
||||
3. On their go-ahead: \`gnubok_import_sie\` with the same file and the
|
||||
preflight's \`mappings\`. It stages for approval; after commit verify with
|
||||
\`gnubok_get_trial_balance\`.
|
||||
2. When the file arrives, get its BYTES to the server without retyping
|
||||
them. Preferred (and REQUIRED for anything beyond a small file): call
|
||||
\`gnubok_create_sie_upload\`, PUT the raw file bytes to the returned
|
||||
\`upload_url\` (from your code sandbox when you have one), compute the
|
||||
file's sha256, then call \`gnubok_sie_preflight\` with \`upload_id\` +
|
||||
\`sha256\` + the same \`filename\`. Small files may go inline
|
||||
(\`file_content_base64\` + \`sha256\` preferred over plain
|
||||
\`file_content\`). NEVER reproduce a large file token by token: the
|
||||
tools refuse oversized inline content because a mid-verifikat
|
||||
truncation imports silently incomplete bookkeeping.
|
||||
3. Summarize the preflight in a few lines: source system, fiscal years,
|
||||
verifikat count, balance status, org-number match, the one warning that
|
||||
matters. On the user's go-ahead: \`gnubok_import_sie\` with the same
|
||||
source (\`upload_id\` or content) and the preflight's \`mappings\`. It
|
||||
stages for approval; after commit verify with
|
||||
\`gnubok_get_trial_balance\`, and explain any skipped voucher numbers
|
||||
with \`gnubok_explain_voucher_gap\` (BFNAR 2013:2; an unexplained gap
|
||||
blocks year-end).
|
||||
4. Multiple fiscal years = multiple files: import oldest first so IB/UB
|
||||
chains. If the file is very large for chat, the web wizard at
|
||||
\`/import?mode=sie\` is the fallback; Fortnox users can also run the full
|
||||
API migration (invoices, customers, documents) at
|
||||
chains. The web wizard at \`/import?mode=sie\` is the fallback when no
|
||||
upload path works; Fortnox users can also run the full API migration
|
||||
(invoices, customers, documents) at
|
||||
\`/import?mode=migration&provider=fortnox\`.
|
||||
|
||||
## Step 4: connect bank and Skatteverket (together, no pause)
|
||||
@@ -136,6 +156,7 @@ message instead of making them ask.
|
||||
|
||||
- \`gnubok_lookup_company\`: registry facts + prefill from the orgnr; call first
|
||||
- \`gnubok_create_company\`: preview (no confirm) then create (confirm=true)
|
||||
- \`gnubok_create_sie_upload\`: byte-exact upload URL for the SIE file
|
||||
- \`gnubok_sie_preflight\`: scan a shared SIE file, nothing written
|
||||
- \`gnubok_import_sie\`: staged import; use the preflight's mappings
|
||||
- \`gnubok_connect_bank\` / \`gnubok_connect_skatteverket\`: status + connect links
|
||||
|
||||
@@ -340,6 +340,8 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_export_sie: 'reports:read',
|
||||
gnubok_audit_package: 'reports:read',
|
||||
gnubok_import_sie: 'bookkeeping:write',
|
||||
// Byte-exact SIE upload URL feeding gnubok_import_sie (same write intent).
|
||||
gnubok_create_sie_upload: 'bookkeeping:write',
|
||||
// Rot/rut begäran om utbetalning (records a payout request on generate)
|
||||
gnubok_generate_rot_rut_file: 'invoices:write',
|
||||
// Supplier CRUD
|
||||
|
||||
Reference in New Issue
Block a user