Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ The format follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/

## [Unreleased]

### Added

- **`andamio course import-assignment <course-id> <module-code> <file.json>`** — publishes a quiz assignment (a `{"type": "quiz", "version": 1, …}` envelope, the format the Andamio app grades client-side) as the module's `assignment.content_json`, verbatim, sending only the `assignment` key. Until now this was a hand-built `curl` against the module-update endpoint with the JWT copied out of `~/.andamio/config.json` — the gap #62 closed for module creation, reopened for quizzes.

The envelope is validated before any request with the union of the rules the two Andamio apps enforce (fcb-fan-engagement-app and andamio-app-v2, each `src/lib/quiz/quiz-envelope.ts`, pinned by fixtures under `testdata/quiz/`) — any course on the gateway is viewable in app.andamio.io, so a quiz the CLI accepts renders in both. Every violated rule is listed, and there is no bypass flag. The existing assignment's title, description, image and video URLs are preserved unless `--title` / `--description` override them. After the update the module is re-fetched and the stored value deep-compared to the file, so the command proves the opaque-`jsonb` assumption on the live gateway rather than trusting it. `--dry-run` prints the summary (question count, pass threshold, question ids, title source) and sends nothing; `--show-payload` adds the payload; `--output json` emits `{course_id, module_code, module_status, assignment: {title, title_source, question_count, pass_threshold, question_ids}, verified}`.

Works on published modules too: db-api's aggregate update soft-skips only SLTs on a non-DRAFT module and edits assignments in any status. That statement rests on the gateway and db-api source as of this change; the live preprod check on an `ON_CHAIN` module had not been run when it was written. (#165)

- **`kind: verify` in the `--output json` error envelope** — a write the gateway accepted but whose read-back did not confirm the stored value: it differs from what was sent, the read-back was degraded (206), or the read-back request itself failed. Every failure after the accepted write classifies as `verify`; the cause stays inspectable through `errors.Unwrap`. It shares exit 1 with the other kinds that are already distinguishable by name. The distinction matters because the alternatives both mislead: success would hide that the stored value is wrong, `server` would hide that the module *was* modified. Emitted by `course import-assignment`. Additive — no existing kind changes. (#165)

- **`assignment.quiz.json` in the module directory format.** `course import <dir>` sends it verbatim as the assignment's `content_json` after validating it as a v1 quiz, preserving the existing title; a directory holding both `assignment.md` and `assignment.quiz.json` is refused before any request, and so is `--create` for a module that does not exist yet (a quiz file carries no title; publish it with `import-assignment --title` after the module exists). `--dry-run` reports `Assignment: quiz (N questions, threshold M)` and `--output json` gains an additive `assignment_quiz` summary object. (#165)

### Fixed

- **`course export` no longer destroys a quiz assignment.** It ran the Markdown converter over the envelope, matched no node type, and wrote an empty `assignment.md` — which a later `course import` of that directory published as the assignment, replacing the quiz with an empty text document. A non-`doc` assignment is now written verbatim to `assignment.quiz.json` and no `assignment.md` is produced, so export followed by import of a quiz module is a server-side no-op. Re-exporting into the same directory with `--force` removes whichever assignment file no longer matches the module, both when the assignment is gone, and says so on stderr. (#165, #59)

## [1.0.0] - 2026-08-27

**Andamio CLI 1.0 is a developer tool for the people who author work and assess it: course Owners and Teachers, and project Managers.**
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ Export and import are the two complex commands. They follow a different pattern:
7. **SLT locking** — import checks module status; skips sending SLTs for non-DRAFT modules to avoid `SLT_LOCKED` errors.
8. **Tiptap node types** — standalone images use `imageBlock` (with `width: "600"`, `align: "center"` attrs), not `image`. Matches app's `markdown-to-tiptap.ts`.
9. **Goldmark TextBlock** — tight list items use `ast.TextBlock`, not `ast.Paragraph`. Both are handled identically in the converter.
10. **Quiz assignments** — an assignment whose `content_json.type` is not `doc` (a quiz envelope, see CONCEPTS.md) is exported verbatim to `assignment.quiz.json` with no `assignment.md`, and import sends `assignment.quiz.json` back verbatim after validating it as a v1 quiz through `internal/quiz`. Both files present is a parse-time error. The validator enforces the union of both apps' `validateQuizDefinition` rules (fcb-fan-engagement-app and andamio-app-v2); `testdata/quiz/SOURCE.md` records the commits the fixtures were copied from, and a rule change in either app is re-mirrored by hand.

### Auth Flow

Expand All @@ -108,7 +109,7 @@ Three auth slots coexist in config:
- **User JWT** (`user login`) — browser-based wallet signing flow: starts ephemeral local HTTP server, opens browser to `{appURL}/auth/cli?redirect_uri=...&state=...`, user connects Cardano wallet and signs nonce, receives JWT via callback. CSRF protection via random state parameter. Required for edit operations on course/project commands. Headless variant: `user login --skey --alias --address`.
- **Developer JWT** (`dev login`) — supports two modes. **Browser mode** (default, `dev login` with no args) opens `{appURL}/auth/dev-cli` and waits for a wallet-signed nonce from Eternl/Lace/Nami via an ephemeral localhost callback — the typical developer journey, since browser wallets don't expose `.skey` files. The browser-flow callback is **POST + JSON** (`Content-Type: application/json`) per `andamio-app-v2#699`'s `DevCliSuccessPayload` / `DevCliErrorPayload`, with an `OPTIONS` preflight serving CORS + `Access-Control-Allow-Private-Network: true` so Chrome's PNA spec permits the HTTPS-origin → 127.0.0.1 POST. The listener enforces an exact-string `Origin` allow-list (derived from `cfg.BaseURL` via `.api.`→`.app.` swap) on browser-originated POSTs; loopback diagnostics without an `Origin` header are still accepted. The user-login browser flow (`/auth/cli`) deliberately stays on **GET + query params** (lower-sensitivity payload, no 30-day refresh token); the two flows have intentionally different wire formats. **Headless mode** (`dev login --skey --alias --address`) signs locally for CI/CD, ops, and devkit. Both call andamio-api's CIP-30 signature-verified login endpoints (#410). Two-step flow: `POST /v2/auth/developer/login/session` opens a 5-min session keyed to `(alias, wallet_address)` and returns a nonce; the CLI signs the nonce locally with `internal/cardano.SignMessage`; `POST /v2/auth/developer/login/complete` submits the signature and receives a 60-minute RS256 JWT plus a 30-day single-use rotation refresh token. The dev JWT is required for `/v2/keys`, `/api/v2/apikey/developer/*`, and other developer-portal endpoints — the gateway's `developerJWTAuth` middleware does not accept wallet/user JWTs and vice versa. These surfaces are **dual-credential**: the gateway's `V2AuthMiddleware` requires `X-API-Key` and the inner `developerJWTAuth` requires `Authorization: Bearer <devJWT>`. The CLI's `devKeysClient` helper (`cmd/andamio/dev_keys.go`) is the shared routing for any dual-credential dev-portal surface — preserves `APIKey`, promotes `DevJWT` into the JWT slot, both headers ride on the wire. `dev keys` and `apikey usage`/`profile` both route through it; new dev-portal commands should too. Distinct config slot (`dev_jwt` + `dev_refresh_token`) so the two JWTs don't clobber each other. `dev refresh` rotates without re-signing (uses the refresh token); a 401 from refresh clears the dev slot and instructs re-login. `dev logout` clears the entire dev slot whenever **either** `dev_jwt` **or** `dev_refresh_token` is persisted (the durable 30-day refresh token gets cleared even when the 60-min JWT is empty). Override at runtime via `ANDAMIO_DEV_JWT` and/or `ANDAMIO_DEV_REFRESH_TOKEN` env vars (parallel to `ANDAMIO_JWT` for the user slot — the refresh-token override is the path for ephemeral CI/CD agents that want to rotate without committing tokens to the image). **Ephemeral by design:** env-sourced credentials (`ANDAMIO_JWT` / `ANDAMIO_DEV_JWT` / `ANDAMIO_DEV_REFRESH_TOKEN`) are NOT persisted to disk on `Save` — `Load` snapshots the env values and `Save` strips fields whose current value still matches the snapshot. Rotation works normally: `dev refresh` mutates the in-memory token to the gateway-rotated value (which differs from the snapshot) and that new value IS persisted, so subsequent CLI commands in the same job pick it up. The legacy lookup-only `/v2/auth/developer/account/login` is intentionally not used — it returns 410 Gone behind the gateway's kill-switch flag and does not prove wallet ownership.

**Local JWT expiry handling (#134).** `internal/config/jwt.go` decodes a token's `exp` claim locally (payload base64 only, no signature verification — the decoded value drives a send/don't-send decision; the gateway stays the authority) with a conservative 30s skew: expired means `now >= exp - 30s`, so the CLI never sends a token the gateway might already reject. Four enforcement points, in request order: (1) `client.New` drops a locally-expired **user-slot** JWT from its own field snapshot (config is never mutated, so no `Save` can persist the clear) and prints one stderr warning per process — resettable `warnOnce`, emitted in every output mode, env-aware wording (`ANDAMIO_JWT` vs `user login`); (2) `requireUserAuth` (helpers.go) fails fast with exit 3 / `kind: auth` + expiry timestamp on all JWT-required commands — `jwtAuthPreRunE` parents AND the seven hand-rolled PreRunEs (`course export/import/import-all/create-module`, `tx build/register/run`); (3) `devKeysClient` fail-fasts on an expired **dev** JWT with a `dev refresh` hint *before* promoting it into the shared UserJWT slot — ordering is load-bearing: it guarantees the client-level drop can never silently strip a dev JWT on a dual-credential surface (the 0.12.x regression class); (4) either-auth course reads route teacher-vs-user endpoints via `HasFreshUserAuth`, not `HasUserAuth`, so an expired JWT + API key lands on the user endpoint and succeeds. **Fail open on undecodable tokens everywhere** — non-JWT strings (incl. the `"test-jwt"` test fixtures) are sent as-is, never treated as expired. Login flows are self-healing: the browser guard treats expired as unauthenticated, and headless login builds its client from a cfg copy with `UserJWT` blanked (login never needs prior user auth — this covers even tokens the CLI cannot decode). Headless login persists `jwt_expires_at` from the decoded `exp`; `user status` falls back to decoding `exp` when the stored field is empty and computes `session_expired` with the same skew predicate as enforcement (probe and enforcement must agree).
**Local JWT expiry handling (#134).** `internal/config/jwt.go` decodes a token's `exp` claim locally (payload base64 only, no signature verification — the decoded value drives a send/don't-send decision; the gateway stays the authority) with a conservative 30s skew: expired means `now >= exp - 30s`, so the CLI never sends a token the gateway might already reject. Four enforcement points, in request order: (1) `client.New` drops a locally-expired **user-slot** JWT from its own field snapshot (config is never mutated, so no `Save` can persist the clear) and prints one stderr warning per process — resettable `warnOnce`, emitted in every output mode, env-aware wording (`ANDAMIO_JWT` vs `user login`); (2) `requireUserAuth` (helpers.go) fails fast with exit 3 / `kind: auth` + expiry timestamp on all JWT-required commands — `jwtAuthPreRunE` parents AND the eight hand-rolled PreRunEs (`course export/import/import-all/import-assignment/create-module`, `tx build/register/run`); (3) `devKeysClient` fail-fasts on an expired **dev** JWT with a `dev refresh` hint *before* promoting it into the shared UserJWT slot — ordering is load-bearing: it guarantees the client-level drop can never silently strip a dev JWT on a dual-credential surface (the 0.12.x regression class); (4) either-auth course reads route teacher-vs-user endpoints via `HasFreshUserAuth`, not `HasUserAuth`, so an expired JWT + API key lands on the user endpoint and succeeds. **Fail open on undecodable tokens everywhere** — non-JWT strings (incl. the `"test-jwt"` test fixtures) are sent as-is, never treated as expired. Login flows are self-healing: the browser guard treats expired as unauthenticated, and headless login builds its client from a cfg copy with `UserJWT` blanked (login never needs prior user auth — this covers even tokens the CLI cannot decode). Headless login persists `jwt_expires_at` from the decoded `exp`; `user status` falls back to decoding `exp` when the stored field is empty and computes `session_expired` with the same skew predicate as enforcement (probe and enforcement must agree).

The app URL is derived from the API URL by replacing `.api.` with `.app.` in the hostname.

Expand Down Expand Up @@ -141,6 +142,7 @@ Every failure carries an exit code **and**, under `--output json`, a `kind` fiel
|------|--------|------|
| 0 | — | Success, including an empty but valid result set |
| 1 | `error` / `server` / `backpressure` / `canceled` | Unexpected, 5xx, retry-later, interrupted |
| 1 | `verify` | A write was accepted but the read-back did not confirm the stored value (differs, or degraded 206). Emitted by `course import-assignment`. Shares exit 1 per the main.go rule; the module WAS modified |
| 2 | `not_found` | 404 |
| 3 | `auth` | No credentials, or 401/403 |
| 4 | `removed_command` | Retired in 1.0 |
Expand Down Expand Up @@ -211,6 +213,7 @@ Exit codes 0–3 predate 1.0 and are fixed. `conflict` moved from 1 to 6 in 1.0.
| `course teacher commitments` | `/v2/course/teacher/assignment-commitments/list` | jwt | List pending reviews. `--course-id` |
| `course credential verify-hash <course-id>` | `/api/v2/course/user/modules/{id}` | either | Verify credential hashes match computed SLT hashes |
| `course credential compute-hash` | local | none | Compute SLT hash from `--slt` flags or `--file` (outline.md). No auth required |
| `course import-assignment <course-id> <module-code> <file.json>` | `/v2/course/teacher/course-modules/list` + `/v2/course/teacher/course-module/update` | jwt | Publish a quiz envelope (`{"type":"quiz","version":1,…}`) verbatim as the module's `assignment.content_json`, sending only the `assignment` key. Validates before any request (no bypass flag), preserves existing title/description/image_url/video_url (`--title`/`--description` override), then re-fetches and deep-compares; any failure after the accepted write (mismatch, degraded read-back, failed re-fetch) is `kind: verify`. `--course`, `--dry-run`, `--show-payload`. Assignments are editable in any module status; only SLTs lock |

### project — Project data
| Command | Endpoint | Auth | Description |
Expand Down
5 changes: 5 additions & 0 deletions CONCEPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ The content-derived identity of a Course Module: a digest computed over the orde

Both the CLI and the chain compute it independently from the same content, which is what makes it a linkage key rather than a checksum: two records agree that they describe the same module precisely when their SLT Hashes match. A mismatch is therefore not corruption to repair but a statement that these are different modules.

### Quiz Envelope
An assignment whose `content_json` is a `{"type": "quiz", "version": 1, ...}` object instead of a Tiptap `doc`. The gateway and db-api store it as opaque JSON; only the Andamio app's render layer interprets it, grading client-side and storing a self-contained evidence snapshot on commit. Its validity rules are owned by the app (`src/lib/quiz/quiz-envelope.ts` in fcb-fan-engagement-app); the CLI mirrors them so an envelope it publishes is one the app can render.

On disk a quiz assignment is `assignment.quiz.json`, never `assignment.md`: converting the envelope to Markdown loses it, so export and import carry it verbatim.

## Project

### Task
Expand Down
Loading