Skip to content

client-ts: osc token command and SDK methods, including osc token audit read and NDJSON/CSV export (ADR-0068 T09) #170

Open

Description

@birme

Part of Eyevinn/osaas-token-service#115.
Blocked by: Eyevinn/osaas-token-service#118, Eyevinn/osaas-token-service#119

Source of truth

ADR-0068 (https://github.com/Eyevinn/agent-team-osc-dev/blob/main/adr/0068-fine-grained-access-tokens.md), epic Eyevinn/osaas-token-service#115, birme decision https://github.com/Eyevinn/osaas-token-service/issues/115#issuecomment-6614344808 (D1 split approved, D2 option A, D3, release mechanism). The architect sequenced this work and cut nothing; if a premise below is false, post ## Spec Invalid (see Verify first). The API this ticket calls is defined by T03 (Eyevinn/osaas-token-service#118, /tokens*) and T15 (Eyevinn/osaas-token-service#119, GET /audit/events); their merged code is the contract, not this text.

ADR-0068 text this ticket implements (verbatim; the ADR is the source of truth)

6. Management API (table)

Endpoint Purpose
POST /tokens Create from a template or policy. The token value is returned once.
GET /tokens, GET /tokens/{id} Paginated list (tenant-scoped) and detail.
PUT /tokens/{id}/policy Replace the policy. Takes expectedVersion (optimistic concurrency) and bumps policyVersion. This is the "change scope, same token" call.
PATCH /tokens/{id} Change name, description or status. Not exp.
DELETE /tokens/{id} Delete the record and set Redis status to revoked.
POST /tokens/{id}/simulate Dry-run: would action X on resource Y be allowed?

8. Export (excerpt)

Export. Available in both UIs for the current filter. Formats are NDJSON (full fidelity) and CSV (flat, with the diff as a JSON string). Timestamps are ISO-8601 UTC.

  • It is streamed through an osaas-app route handler that pages the token-service cursor API, so bytes flow continuously and the 60 s ingress idle timeout does not apply. A single export is capped at 500,000 events or a 31-day range, whichever is first, and larger ranges are fetched in chunks.
  • Each export is itself recorded as audit.exported with the actor, filter and row count.
  • The CLI and MCP get read access to the same data (osc token audit, a read-only MCP tool), with the same tenant scoping.

9. Surfaces

osaas-app (decision 8), an osc token CLI command in client-ts, MCP tools in osaas-ai, and a Terraform resource osc_access_token in terraform-provider-osc. All call the same API.

Delivers

SDK functions in @osaas/client-core for every /tokens* route of T03 and for GET /audit/events of T15, and an osc token command group in @osaas/cli that uses them, including osc token audit for reading and exporting audit events as NDJSON or CSV. No other package changes behaviour.

Readiness precondition (checked by the Triage pass, not by the implementer)

This ticket is created in Triage and moves to Backlog only when both probes hold on dev:

curl -s -o /dev/null -w "%{http_code}" -X POST https://token.svc.dev.osaas.io/tokens
curl -s -o /dev/null -w "%{http_code}" https://token.svc.dev.osaas.io/audit/events

Met: the first prints 401 (404 means TOKENS_API_ENABLED is off on dev, 503 means the token store is not configured) and the second prints 401 (503 means the audit store is not configured, 404 means T15 is not deployed). Both checks are unauthenticated and change nothing. A Triage pass that finds a probe unmet posts a NEW ## Architect Triage comment classified Checkpoint (not ready), with one Unverified: line per failed probe that cites the Phase 0 dev-values row of Eyevinn/osaas-token-service#115; the second consecutive such comment labels this ticket Human Needed through the checkpoint rule, and birme removes the label after setting the dev values. A pass never refreshes an earlier checkpoint in place.

Verify first (before any code)

Re-run every grep and citation below against current main (read on 2026-11-05 at client-ts ce30cb2; counts and line numbers drift). If a premise is false (a file, route, field, function or credential does not exist), post ## Spec Invalid on this ticket stating what is actually true, and stop; do not route around it.

  1. Read the merged T03 and T15 code in Eyevinn/osaas-token-service (src/ route schemas and their tests) and take from it, not from this ticket: the request body of POST /tokens (name, description, template or policy, lifetime field), the response field that carries the token value, the list pagination parameters and response fields of GET /tokens, the body of PUT /tokens/{id}/policy (policy, expectedVersion) and PATCH /tokens/{id}, the body and response of POST /tokens/{id}/simulate, the query parameter and response field names of the GET /audit/events cursor, and the export=true query. Quote the names you used in the PR body with the token-service commit SHA.
  2. packages/core/src/context.ts: getPlatformEnvironment() exists and getServiceAccessToken calls https://token.svc.${platformEnv}.osaas.io/servicetoken with 'x-pat-jwt': Bearer <PAT> (lines 91-118 on ce30cb2). The new functions use the same host derivation (prod-se maps to prod) and the same header.
  3. packages/core/src/fetch.ts: createFetch<T>(url, options, errorFactory?) and FetchError with httpCode; the default error factory uses message ?? reason and drops any code field.
  4. packages/cli/src/cli.ts: there is no token command today (it registers cmdMyapp(), cmdUser.cmdServiceAccessToken() as service-access-token, and cmdAdmin() which has gen-pat), global options --env <environment> and --json, and the preAction hook ensureToken(environment). Re-check that osc token still collides with nothing.

Directions

  • SDK (packages/core/src/accesstoken.ts, exported from packages/core/src/index.ts): listAccessTokens(ctx, opts?) (one page, with the T03 pagination parameters), getAccessToken(ctx, id), createAccessToken(ctx, body), replaceAccessTokenPolicy(ctx, id, policy, expectedVersion), updateAccessToken(ctx, id, patch) (name, description, status, enforcement, auditLevel as T03 accepts them; the TypeScript type has no exp field), deleteAccessToken(ctx, id) (no Content-Type header on DELETE, as clearSuspensionRecord does), simulateAccessToken(ctx, id, action, resource), listAuditEvents(ctx, filter, cursor?) (one page) and iterateAuditEvents(ctx, filter, { export }) (an async generator over pages that sends export=true on every page when export is set and carries the opaque cursor unchanged). Base URL https://token.svc.${ctx.getPlatformEnvironment()}.osaas.io, header 'x-pat-jwt': 'Bearer ' + ctx.getPersonalAccessToken(). Types (AccessToken, TokenPolicy, PolicyStatement, AuditEvent, AuditFilter) are written in this file from the T03 and T15 schemas. Use a module-local error factory that keeps the server's code in the error message (for example insufficient_scope) without changing FetchError or the default factory for existing callers.
  • No @osaas/token dependency (dependency policy, four points): (1) the CLI needs only request and response types, which own code covers; (2) it would use none of the library's runtime (verifier, Redis pipeline, compiler); (3) npm view @osaas/token@0.13.0 dependencies lists ioredis and fast-jwt, which a CLI does not need; (4) the sensitive part (policy evaluation) stays server-side in token-service. The server validates every policy; the CLI does no local policy validation beyond parsing JSON.
  • CLI (packages/cli/src/token/cmd.ts, cmdToken() added in cli.ts with cli.addCommand), following myapp/cmd.ts: environment from command.optsWithGlobals().env, --json writes structured JSON to stdout and human text to stderr, errors as { "error": ... } on stderr in JSON mode, and process.exitCode = 1 on any failure (as admin/cmd.ts does).
    • osc token list: follows every page; prints id, name, status, expiry, policyVersion, last used. When the response carries scope: "own" print Showing your own tokens only to stderr, and when it carries roleLookup: "unavailable" print a warning that the workspace role could not be resolved and the list may be incomplete (T03 degraded signal).
    • osc token describe <id>.
    • osc token create --name <name> [--description <text>] (--template <name> | --policy <file|->) [--expires-in-days <n>]: exactly one of --template and --policy (else exit 1 before any request); help lists the six templates of ADR decision 2 (viewer, deployer, ci-publisher, agent-runner, mcp-readonly, full) and the server decides validity. The token value is printed once: in text mode the value alone on stdout and the metadata plus "this value is shown once and cannot be retrieved again" on stderr, so TOKEN=$(osc token create ...) captures only the value; in --json mode the whole create response on stdout. The value is never written to disk, never passed to saveToken, and never logged (no Log().debug of a body).
    • osc token set-policy <id> --policy <file|-> --expected-version <n>: a 409 prints that the policy changed since version n and exits 1 without retrying.
    • osc token update <id> [--name] [--description] [--enforcement enforce|audit] [--audit-level default|full], plus osc token disable <id> and osc token enable <id> (PATCH status). There is no option to change the expiry: ADR decision 1 fixes exp; the help of update says to create a new token instead.
    • osc token delete <id> [--yes]: asks for confirmation with the existing confirm helper from user/util unless --yes.
    • osc token simulate <id> --action <action> --resource <resource>: prints allowed or denied and the reason.
    • osc token audit [--token <patId>] [--user <userId>] [--kind <kind>] [--action <action>] [--decision allow|deny] [--from <iso>] [--to <iso>] [--limit <n>]: a read (no export=true), newest first, default limit 100, table or --json. With --export ndjson|csv [--output <file>] it is an export: every page is requested with export=true and written as it arrives (never buffered whole), to stdout or --output. NDJSON is one event per line, unchanged. CSV has one header row with the event fields of ADR decision 7 in ADR order, RFC 4180 quoting, details as a JSON string, timestamps ISO-8601 UTC; the CSV writer is own code. --from and --to are required for an export; a range over 31 days is split into consecutive windows of at most 31 days, each its own export, and a window that reaches 500,000 rows stops with a stderr line naming the last ts written so the user can continue from it (ADR decision 8 cap). The CLI posts no audit.exported event: token-service records one per export page (T15). The same scope and roleLookup notices as list go to stderr; a 403 insufficient_role (filter on another user as a non-manager) or insufficient_scope prints the reason and exits 1.
  • Status handling (new commands only): POST /tokens answering 404 prints Creating access tokens is not enabled in <env> yet (T03 keeps the route behind TOKENS_API_ENABLED, off in prod until T13); a 503 prints the server reason (token store not configured, audit store not configured); a 404 on an {id} route prints the server reason (token not found or role lookup unavailable).
  • Do not change any package.json version, run lerna version, npm run lerna:version or npm run publish, or write a commit message containing chore(release): .github/workflows/main.yml publishes to npm on a push whose message contains chore(release): publish, and releasing client-ts is birme's act.

Acceptance criteria

  • packages/core exports the SDK functions and types above; npm run typecheck, npm run lint, npm run pretty and npm run test pass at the repo root (the checks of .github/workflows/pull-request.yml).
  • Core unit tests (jest, createFetch mocked as in suspension.test.ts): each function calls the expected method and URL on token.svc.dev.osaas.io for environment: 'dev' and on token.svc.prod.osaas.io for environment: 'prod-se', with x-pat-jwt: Bearer <pat>; DELETE sends no Content-Type; iterateAuditEvents with export sends export=true on every page and passes each returned cursor back unchanged, and without export sends no export parameter; the error factory turns 403 {reason, code: 'insufficient_scope'} into a FetchError with httpCode 403 whose message contains both.
  • CLI tests (mocking @osaas/client-core createFetch, as admin/cmd.syncfork.test.ts does), rejection side: create with both --template and --policy, or neither, exits 1 with no request; set-policy on 409 exits 1 and does not retry; audit --user other on 403 insufficient_role exits 1 and prints the reason; create on a 404 from POST /tokens prints the not-enabled message and exits 1; audit --export without --from and --to exits 1 with no request.
  • CLI tests, passing path: create --template viewer prints only the token value on stdout in text mode and the full response in --json mode, and the value appears in no other output stream and in no file; list across two pages prints both pages and the scope: "own" notice; delete --yes sends DELETE without prompting; audit --export ndjson over two pages writes exactly the rows returned, one JSON object per line, in order; audit --export csv writes one header row and quotes a field containing a comma, a quote and a newline correctly, with details as a JSON string; an export over a 40-day range makes two windows (31 and 9 days).
  • No new runtime dependency in any package.json; package-lock.json changes only for the workspace links if at all.
  • Agent-run dev check (needs test_pat=dev; tenant test_tenant), run from the PR branch after npm run build, with OSC_ACCESS_TOKEN=$TEST_PAT node packages/cli/dist/cli.js --env dev ...: (1) token create --name t09-check --template viewer --expires-in-days 1 returns a value; (2) token describe <id> shows policyVersion 1; (3) token set-policy <id> --policy <file> --expected-version 1 succeeds and describe shows version 2 with the same id; (4) token disable <id> then describe shows disabled; (5) token audit --export ndjson --from <now-10m> --to <now> writes N rows; (6) token audit --kind audit.exported --from <now-10m> --to <now> --json (a read, not an export) shows audit.exported events whose details.exportId values are equal within that export and whose count values sum to N; (7) token delete <id> --yes. The PR body quotes the commands and outputs with the token value replaced by <redacted>. A 401 or 403 from dev on these calls is the missing capability to name, not a reason to change code.
  • Closing comment (What a human does next): to make the commands public, birme releases client-ts with its lerna flow (npm run lerna:version on main, which makes the chore(release): publish commit that main.yml publishes from); until T13 has switched TOKENS_API_ENABLED on in prod, osc token create against prod prints the not-enabled message. How to test after a release: OSC_ACCESS_TOKEN=<dev PAT> osc --env dev token create --name check --template viewer, expected a token value on stdout; osc --env dev token audit --export csv --from <iso> --to <iso>, expected a CSV header and rows.

Executor and authorisation (EyevinnOSC pipeline)

Facts read on 2026-11-05 (gh with birme's account, read-only):

Implementer: clone EyevinnOSC/client-ts into the scratchpad, git fetch origin, branch from origin/main, and push from there; never run git remote set-url|add|rename|remove in the team repository. Fallback when a pipeline step cannot run here (the push, gh pr create, the review, or the App merge is refused): the implementer posts a comment on this ticket starting with ## Pipeline blocked that names the step and quotes the error, and stops without trying another identity or route; the orchestrator labels the ticket Human Needed with the question for birme: merge by hand, or install the eyevinn-developer and eyevinn-pr-reviewer Apps in EyevinnOSC. A hand merge closes this ticket through the PR's closing keyword and unblocks T19 and the parent. If the comment on this ticket is itself refused (the same Issues scope gap), post it on Eyevinn/osaas-token-service#115 with this ticket's full reference instead.

PR closing keywords

The PR body carries Closes EyevinnOSC/client-ts#<this ticket's own number> on its own line and no other closing keyword.

PR body requirements

## Lessons section; the Co-Authored-By: Claude <noreply@anthropic.com> trailer and the Claude Code footer; a What a human does next statement; the four dependency-policy points above (no new dependency); the T03 and T15 field names used, with the token-service SHA. Every new rejection branch has a passing-path test in the same PR. No ticket is created for a person to configure, verify or release anything.

Contract

Everything is new: new SDK exports, a new CLI command group, calls to new token-service routes. No existing command, export or endpoint changes behaviour.

Unverified: the exact request and response field names of /tokens* and GET /audit/events (T03 and T15 are not merged; Verify first item 1); whether the runner token can create a PR, comment or apply labels in EyevinnOSC/client-ts (only push is reported, and createIssue was refused; until birme grants Issues and Pull requests write on this repository, every agent write to this ticket, including the Triage checkpoint comments above, may fail); whether the runner holds admin here (read only for birme's account); whether TEST_PAT is accepted by GET /audit/events on dev with the role rule giving it the tenant (the dev check reports a 401 or 403 as the missing capability); whether osaas-e2e-tests covers any osc CLI command (not grepped; the command names are new).

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 5.5 noreply@anthropic.com

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ai-devopsPicked up by AI DevOps daily focus

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions