@voltro/cli 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/dist/apiBuild-BOqL1dlg.js +2 -0
  3. package/dist/{apiBuild-DmkzRyv7.js → apiBuild-CLPt-pX6.js} +2 -2
  4. package/dist/appGraph-CvQCte0z.js +2 -0
  5. package/dist/appGraph-gQ_6GkQQ.js +110 -0
  6. package/dist/bin.js +3 -3
  7. package/dist/checkCommand-ClRvH_SN.js +2 -0
  8. package/dist/{checkCommand-C4konUy9.js → checkCommand-D7r6_pk7.js} +439 -530
  9. package/dist/{commands-BkJft9K2.js → commands-CNVVv03h.js} +1941 -1880
  10. package/dist/{dbCommand-qn__kLEt.js → dbCommand-BH8GAYuf.js} +76 -19
  11. package/dist/dbCommand-BwQ_Jl98.js +2 -0
  12. package/dist/{dev-DXhhZ7-f.js → dev-DOZVMVfi.js} +1 -1
  13. package/dist/{dev-C_NPcLDp.js → dev-DdQEn2Ia.js} +1986 -1967
  14. package/dist/{frameworkTableAssembly-Cpgn2e88.js → frameworkTableAssembly-BwIrO5nv.js} +122 -103
  15. package/dist/frameworkTableAssembly-D-EebUQX.js +2 -0
  16. package/dist/index.js +1 -1
  17. package/dist/{inspectMetrics-CkB2J0Zn.js → inspectMetrics-BeEAnjqw.js} +774 -527
  18. package/dist/{sdkgen-wsWLrq4l.js → sdkgen-v9WV8zRi.js} +1 -1
  19. package/dist/{serveCommand-HtI4X5kj.js → serveCommand-KsKXXWn4.js} +294 -293
  20. package/dist/serveEntry.js +2 -2
  21. package/dist/{start-egkbpugc.js → start-CI2QdKy9.js} +351 -346
  22. package/dist/startEntry.js +2 -2
  23. package/package.json +17 -17
  24. package/templates/AGENTS.md +43 -43
  25. package/templates/agent-docs/_index.md +43 -43
  26. package/templates/agent-docs/_manifest.json +84 -42
  27. package/templates/agent-docs/cli.md +4 -2
  28. package/templates/agent-docs/database/migrations.md +14 -1
  29. package/templates/agent-docs/internationalization.md +54 -3
  30. package/templates/agent-docs/plugins/ai-flows.md +148 -0
  31. package/templates/agent-docs/plugins/analytics-postgres.md +90 -0
  32. package/templates/agent-docs/plugins/atlassian.md +378 -0
  33. package/templates/agent-docs/plugins/audit.md +324 -0
  34. package/templates/agent-docs/plugins/auth-auth0.md +92 -0
  35. package/templates/agent-docs/plugins/auth-clerk.md +90 -0
  36. package/templates/agent-docs/plugins/auth-kinde.md +88 -0
  37. package/templates/agent-docs/plugins/auth-oidc.md +95 -0
  38. package/templates/agent-docs/plugins/auth-supabase.md +103 -0
  39. package/templates/agent-docs/plugins/auth-workos.md +105 -0
  40. package/templates/agent-docs/plugins/auth.md +227 -0
  41. package/templates/agent-docs/plugins/billing.md +324 -0
  42. package/templates/agent-docs/plugins/broadcast.md +144 -0
  43. package/templates/agent-docs/plugins/cdc-out.md +116 -0
  44. package/templates/agent-docs/plugins/clickhouse.md +91 -0
  45. package/templates/agent-docs/plugins/datadog.md +105 -0
  46. package/templates/agent-docs/plugins/deactivation.md +114 -0
  47. package/templates/agent-docs/plugins/duckdb.md +82 -0
  48. package/templates/agent-docs/plugins/flags.md +146 -0
  49. package/templates/agent-docs/plugins/governance.md +143 -0
  50. package/templates/agent-docs/plugins/licensing.md +74 -0
  51. package/templates/agent-docs/plugins/logship.md +56 -0
  52. package/templates/agent-docs/plugins/mail.md +334 -0
  53. package/templates/agent-docs/plugins/moderation.md +65 -0
  54. package/templates/agent-docs/plugins/multitenancy.md +99 -0
  55. package/templates/agent-docs/plugins/notifications.md +252 -0
  56. package/templates/agent-docs/plugins/openapi.md +94 -0
  57. package/templates/agent-docs/plugins/postgis.md +140 -0
  58. package/templates/agent-docs/plugins/posthog.md +91 -0
  59. package/templates/agent-docs/plugins/presence.md +115 -0
  60. package/templates/agent-docs/plugins/prometheus.md +124 -0
  61. package/templates/agent-docs/plugins/ratelimit.md +274 -0
  62. package/templates/agent-docs/plugins/rbac.md +270 -0
  63. package/templates/agent-docs/plugins/scim.md +63 -0
  64. package/templates/agent-docs/plugins/search.md +116 -0
  65. package/templates/agent-docs/plugins/sentry.md +192 -0
  66. package/templates/agent-docs/plugins/soft-delete.md +72 -0
  67. package/templates/agent-docs/plugins/sso-saml.md +132 -0
  68. package/templates/agent-docs/plugins/storage.md +709 -0
  69. package/templates/agent-docs/plugins/tinybird.md +92 -0
  70. package/templates/agent-docs/plugins/versioning.md +226 -0
  71. package/templates/agent-docs/plugins/webhooks.md +585 -0
  72. package/templates/agent-docs/security.md +12 -1
  73. package/templates/agent-docs/whats-new.md +81 -37
  74. package/templates/apps/api-ai/package.json +7 -7
  75. package/templates/apps/api-auth/package.json +8 -8
  76. package/templates/apps/api-backend/package.json +7 -7
  77. package/templates/apps/api-backend-deactivation/package.json +7 -7
  78. package/templates/apps/api-backend-mail/package.json +8 -8
  79. package/templates/apps/api-backend-mariadb/package.json +9 -9
  80. package/templates/apps/api-backend-sqlite/package.json +8 -8
  81. package/templates/apps/api-backend-storage/package.json +8 -8
  82. package/templates/apps/api-cms/package.json +10 -10
  83. package/templates/apps/api-collab/package.json +8 -8
  84. package/templates/apps/api-data-advanced/package.json +8 -8
  85. package/templates/apps/api-durable/package.json +8 -8
  86. package/templates/apps/api-feature-flags/package.json +9 -9
  87. package/templates/apps/api-governance/package.json +8 -8
  88. package/templates/apps/api-kv/package.json +8 -8
  89. package/templates/apps/api-moderation/package.json +8 -8
  90. package/templates/apps/api-observability/package.json +8 -8
  91. package/templates/apps/api-ratelimit/package.json +8 -8
  92. package/templates/apps/api-rbac/package.json +8 -8
  93. package/templates/apps/api-rest/package.json +7 -7
  94. package/templates/apps/api-saas/package.json +11 -11
  95. package/templates/apps/api-saas-starter/package.json +10 -10
  96. package/templates/apps/api-search/package.json +8 -8
  97. package/templates/apps/api-status/package.json +8 -8
  98. package/templates/apps/api-versioning/package.json +8 -8
  99. package/templates/apps/api-webhooks/package.json +9 -9
  100. package/templates/apps/changelog/package.json +6 -6
  101. package/templates/apps/edge-functions/package.json +2 -2
  102. package/templates/apps/frontend-admin/package.json +8 -8
  103. package/templates/apps/frontend-app/package.json +8 -8
  104. package/templates/apps/frontend-auth/package.json +8 -8
  105. package/templates/apps/frontend-blank/package.json +7 -7
  106. package/templates/apps/frontend-cms/package.json +9 -9
  107. package/templates/apps/frontend-collab/package.json +9 -9
  108. package/templates/apps/frontend-contact/package.json +7 -7
  109. package/templates/apps/frontend-dashboard/package.json +7 -7
  110. package/templates/apps/frontend-docs/package.json +7 -7
  111. package/templates/apps/frontend-i18n/package.json +6 -6
  112. package/templates/apps/frontend-landing/package.json +7 -7
  113. package/templates/apps/frontend-portal/package.json +8 -8
  114. package/templates/apps/frontend-saas/package.json +8 -8
  115. package/templates/apps/frontend-spa/package.json +7 -7
  116. package/templates/apps/frontend-ssr/package.json +7 -7
  117. package/templates/apps/frontend-ssr-api/package.json +8 -8
  118. package/templates/apps/frontend-static-blog/package.json +6 -6
  119. package/templates/apps/frontend-status/package.json +8 -8
  120. package/templates/apps/mobile-app/package.json +2 -2
  121. package/dist/apiBuild-Ca6Sq5jy.js +0 -2
  122. package/dist/checkCommand-DHD0aBeR.js +0 -2
  123. package/dist/dbCommand-wfGi3FgG.js +0 -2
  124. package/dist/frameworkTableAssembly-Bn_c4Mnl.js +0 -2
@@ -0,0 +1,148 @@
1
+ # AI Flows
2
+
3
+ > Durable multi-step AI pipelines — deterministic or agentic, with human-in-the-loop, chaining, and cadence. Author flows in code (defineFlow) or as data (visual-editor rows); one engine runs both.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/ai-flows.md -->
10
+ ## AI Flows
11
+
12
+ _Durable multi-step AI pipelines — deterministic or agentic, with human-in-the-loop, chaining, and cadence. Author flows in code (defineFlow) or as data (visual-editor rows); one engine runs both._
13
+
14
+ `@voltro/plugin-ai-flows` turns a multi-step AI pipeline into a **durable,
15
+ resumable run**: an orchestrator that chains sub-agents and generation tools
16
+ (text / image / video / audio) into a bundled result, with **human-in-the-loop**
17
+ review, **chaining** (flow A feeds flow B), and **cadence** (scheduling). It's the
18
+ layer above `@voltro/ai` agents — an agent is code; a **flow is data** a
19
+ non-technical user can author in a visual editor and the framework interprets.
20
+
21
+ Two execution modes:
22
+
23
+ - **Deterministic** (`mode: 'deterministic'`) — run the plan steps in order,
24
+ verbatim, no orchestrator LLM. Predictable, cheaper, **resumable per step** (a
25
+ crash/deploy resumes from the first unfinished step via the workflow journal).
26
+ - **Agentic** (`mode: 'agentic'`) — an orchestrator LLM drives the tools and may
27
+ reorder/insert/skip the suggested plan, bounded by `maxSteps`.
28
+
29
+ And two authoring **front doors** that lower to one engine:
30
+
31
+ - **Code-first `defineFlow(...)`** — typed, testable, diffable; validates every
32
+ `{{ref}}` at registration (dangling refs fail at boot, not mid-run).
33
+ - **Data-driven** — a stored `_voltro_ai_flows` row (from a visual editor); hot-editable,
34
+ no deploy.
35
+
36
+ ## Install
37
+
38
+ ```ts
39
+ // app.config.ts
40
+ import { aiFlowsPlugin } from '@voltro/plugin-ai-flows'
41
+ export default { plugins: [aiFlowsPlugin()] } // contributes _voltro_ai_flows + _voltro_ai_flow_runs
42
+ ```
43
+
44
+ ## Author a flow (code-first)
45
+
46
+ ```ts
47
+ import { defineFlow, flowStep } from '@voltro/plugin-ai-flows'
48
+
49
+ defineFlow({
50
+ name: 'blog-article',
51
+ brief: [{ key: 'topic', label: 'Topic', type: 'text', required: true }],
52
+ steps: [
53
+ flowStep.agent({ agentRef: 'writer', prompt: 'Draft a post about {{topic}}', outputKey: 'draft' }),
54
+ flowStep.human({ reviewMode: 'approve', prompt: 'Approve this draft?' }),
55
+ flowStep.media('image', { prompt: 'Hero image for: {{draft}}', outputKey: 'hero' }),
56
+ flowStep.note({ prompt: '# {{topic}}\n\n{{draft}}\n\n![hero]({{hero}})', outputKey: 'final' }),
57
+ ],
58
+ })
59
+ ```
60
+
61
+ Each step writes `context[outputKey]`; later steps reference it as `{{outputKey}}`
62
+ (alongside `{{briefKey}}`). The **result bundle** is `outputKey → value`.
63
+
64
+ ## Step kinds
65
+
66
+ - **`agent`** — delegate to a sub-agent (its system prompt + model), text-only.
67
+ - **`generate`** — `text` / `image` / `video` / `audio` via `@voltro/ai`.
68
+ - **`structured`** — a JSON object.
69
+ - **`note`** — the interpolated prompt itself is the output (no model call).
70
+ - **`human`** — pause for review (`approve` / `choice` / `text`).
71
+
72
+ ## Human-in-the-loop
73
+
74
+ A `human` step sets the run to `waiting` and **parks the workflow without holding
75
+ a worker** (the durable suspend). The user answers via `respondToFlow`; a reject
76
+ ends the run cancelled, otherwise the answer lands in `context` and the run
77
+ resumes. Thousands of runs can wait on review for days at no runtime cost.
78
+
79
+ ## Chaining & cadence
80
+
81
+ - **`chainTo`** launches a follow-up flow on success (result ⊕ input → the child's
82
+ brief); `requireConfirmation` stages it for the user to confirm.
83
+ - **`cadence`** schedules runs — `weekly` (with `intervalWeeks` + `anchorDate`) or
84
+ `monthly` (`weekOfMonth`, incl. `'last'`). `cadenceMatches` / `nextRuns` are
85
+ exported so the editor's "next 3 runs" preview and the scheduler agree exactly.
86
+
87
+ ## Live timeline
88
+
89
+ `_voltro_ai_flow_runs` is reactive (the default): the engine patches the row (`steps[]`, `status`,
90
+ `costMicroUsd`, …) as it runs, and the client's subscription streams the timeline
91
+ over CDC — no polling, no sockets.
92
+
93
+ ## Wiring (once per app)
94
+
95
+ The engine runs as one durable workflow; the operations are server helpers (a
96
+ plugin route's context is subject-only, so the app owns the thin RPC surface):
97
+
98
+ ```ts
99
+ // api/flows/flow.run.workflow.tsx
100
+ export { flowRunWorkflow as default } from '@voltro/plugin-ai-flows/workflow'
101
+
102
+ // api/flows/flow.run.workflow.server.tsx
103
+ import { buildFlowRunExecute, makeMediaGenerator } from '@voltro/plugin-ai-flows'
104
+ export default (ctx) => buildFlowRunExecute(ctx, {
105
+ resolveAgent: (ref) => /* → { system, model } */,
106
+ // No AI Gateway key? Route model strings to a direct provider (or honour a
107
+ // `provider/model` prefix). Absent → every call goes to the gateway.
108
+ resolveModel: (model) => providerFromEnv(),
109
+ // `run.tenantId` is the durable tenant from the run row — pin persistence to
110
+ // it so a resume stores into the right tenant even when the subject has none.
111
+ generateMedia: makeMediaGenerator({ put: ({ data, mediaType, run }) => storage.put({ bytes: data, contentType: mediaType, tenantId: run.tenantId }), ingestUrl: (u, run) => storage.ingestUrl(u, { tenantId: run.tenantId }) }, { resolveModel: (m) => providerFromEnv() }),
112
+ onEvent: (e) => /* notify via @voltro/plugin-notifications */,
113
+ memoryPrefix: (ownerId) => /* long-term memory */,
114
+ })
115
+
116
+ // api/flows/aiFlows.launch.action.server.ts (+ retry / cancel / respond)
117
+ import { launchFlow } from '@voltro/plugin-ai-flows'
118
+ export default (ctx) => (input) => launchFlow(ctx, input)
119
+
120
+ // The plugin OWNS _voltro_ai_flows — import its CRUD helpers over the plugin's table
121
+ // instead of forking your own entity + a store bridge:
122
+ import { getFlow, listFlows, createFlow, updateFlow, deleteFlow } from '@voltro/plugin-ai-flows'
123
+ // e.g. api/flows/aiFlows.list.query.server.ts → (ctx) => () => listFlows(ctx)
124
+
125
+ // api/flows/flows.cadence.cron.tsx
126
+ import { defineSchedule } from '@voltro/runtime'
127
+ import { runCadenceTick } from '@voltro/plugin-ai-flows'
128
+ import { Effect } from 'effect'
129
+ export default defineSchedule({ cron: '*/15 * * * *', timezone: 'Europe/Berlin', handler: (s) => Effect.promise(() => runCadenceTick(s.app)) })
130
+ ```
131
+
132
+ Adopt `@voltro/plugin-versioning` on `_voltro_ai_flows` for automatic edit history.
133
+
134
+ ## Deployment notes
135
+
136
+ - **Durability**: each generation is a durable step — deterministic runs resume
137
+ from the first unfinished step; agentic runs aren't replay-deterministic (LLM
138
+ planner), so a mid-run crash re-plans (`retry` = fresh run).
139
+ - **Cadence + scale-to-zero**: `dormancy: 'sleep'` won't fire an in-process tick —
140
+ use an external waker or `trigger: 'external'`.
141
+ - **Media retention**: not automatic — add a `*.cron.tsx` deleting old artifacts
142
+ via `storage.delete(id)` (a plain retention sweep would orphan the blobs).
143
+ - **Media tenant on resume**: `generateMedia` receives `run.tenantId`, read from
144
+ the durable run row. Pin persistence to it (as above) rather than the caller
145
+ subject — a resumed run executes under a tenant-less system subject, so reading
146
+ the tenant from the subject would fail closed or store into the wrong tenant.
147
+ - **Cost** is tracked in `costMicroUsd` (micro-USD) on the run + per step; project
148
+ to your display currency in the view.
@@ -0,0 +1,90 @@
1
+ # Postgres analytics
2
+
3
+ > First-party lite AnalyticsSink — stores events in the main DataStore, cross-dialect, zero external infra. ~10M events/day ceiling.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/analytics-postgres.md -->
10
+ ## Postgres analytics
11
+
12
+ _First-party lite AnalyticsSink — stores events in the main DataStore, cross-dialect, zero external infra. ~10M events/day ceiling._
13
+
14
+ `@voltro/plugin-analytics-postgres` is the day-1 `AnalyticsSink` — it stores
15
+ events in an `_voltro_events` table on the **main DataStore**, so there's no
16
+ external service to run. It implements all four contract methods (`track` /
17
+ `aggregate` / `timeseries` / `topN`) by compiling to portable SQL that works on
18
+ every dialect (postgres / mysql / mariadb / mssql / sqlite). Pick it first;
19
+ swap to a real OLAP sink (DuckDB / ClickHouse / Tinybird) only once you outgrow
20
+ its **~10M events/day** ceiling — the swap is a one-line config change because
21
+ every sink shares the same contract. For the shared `AnalyticsSink` API and the
22
+ `useAnalytics()` read methods, see [Analytics & warehouse sinks](/docs/plugins/analytics).
23
+
24
+ ## Install
25
+
26
+ ```
27
+ pnpm add @voltro/plugin-analytics-postgres
28
+ ```
29
+
30
+ ## Wiring
31
+
32
+ ```ts
33
+ // app.config.ts
34
+ import { postgresAnalytics } from '@voltro/plugin-analytics-postgres'
35
+
36
+ export default {
37
+ type: 'api' as const,
38
+ name: 'myApi',
39
+ store: 'postgres' as const,
40
+ analytics: postgresAnalytics(),
41
+ }
42
+ ```
43
+
44
+ The framework calls the factory at boot, hands it the `SqlClient` bound to the
45
+ running DataStore, and provides the resulting sink under `AnalyticsSink`. The
46
+ plugin's `_voltro_events` table is auto-merged into your schema, so
47
+ `applySchema()` creates it on first boot — no `*.entity.ts` needed.
48
+
49
+ ## Options
50
+
51
+ `postgresAnalytics(options?)` — all optional:
52
+
53
+ | Option | Type | Default | Notes |
54
+ |---|---|---|---|
55
+ | `sqlClient` | `SqlClient.SqlClient` | the main DataStore's client | Override to land events in a SEPARATE database (e.g. a dedicated analytics Postgres while the app runs MySQL). You must create the events table there yourself. |
56
+ | `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `_voltro_mirror_<table>` (`{ id, data }`) so analytical queries can JOIN events against live user data. |
57
+ | `mirrorPrimaryKey` | `string` | `'id'` | Primary-key column on the mirrored source rows. |
58
+
59
+ ```ts
60
+ analytics: postgresAnalytics({ mirrorTables: ['users', 'teams'] })
61
+ ```
62
+
63
+ ## Environment
64
+
65
+ - **`VOLTRO_EVENTS_TTL_HOURS`** — retention TTL for `_voltro_events`. Default
66
+ `8760` (365 days). A boot retention sweep drains events past the TTL so the
67
+ table never grows without bound.
68
+
69
+ ## Honest ceiling
70
+
71
+ By ~10M events/day, range aggregates over 30 days take >5s on Postgres. Past
72
+ that, switch to [`@voltro/plugin-duckdb`](/docs/plugins/duckdb) (embedded) or
73
+ [`@voltro/plugin-clickhouse`](/docs/plugins/clickhouse) (external). Reads are
74
+ tenant-scoped automatically — `useAnalytics()` stamps the caller's `tenantId`
75
+ onto every `track` and every aggregate.
76
+
77
+ ## Schema introspection
78
+
79
+ The plugin re-exports its events-table descriptor so you can extend the schema
80
+ (add a GIN index on `properties`, partition by month) via your own migration:
81
+
82
+ ```ts
83
+ import { _voltroEventsTable } from '@voltro/plugin-analytics-postgres'
84
+ ```
85
+
86
+ ## See also
87
+
88
+ - [Analytics & warehouse sinks](/docs/plugins/analytics) — the shared
89
+ `AnalyticsSink` contract, the `useAnalytics()` read API, `composeAnalytics`,
90
+ and the CDC-mirror details.
@@ -0,0 +1,378 @@
1
+ # Atlassian
2
+
3
+ > JiraService + ConfluenceService over the Atlassian REST / Greenhopper / Agile APIs, with a per-subject PAT resolver, transient retry, SSRF guard, an avatar proxy, and per-tenant caching.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/atlassian.md -->
10
+ ## Atlassian
11
+
12
+ _JiraService + ConfluenceService over the Atlassian REST / Greenhopper / Agile APIs, with a per-subject PAT resolver, transient retry, SSRF guard, an avatar proxy, and per-tenant caching._
13
+
14
+ `@voltro/plugin-atlassian` exposes `JiraService` + `ConfluenceService` over the Atlassian REST / Greenhopper / Agile APIs. The app supplies a `credentialsResolver({ subject, store })` — the plugin never reads your schema; the caller's credentials are resolved per request from the Subject (populated by `AuthMiddleware`).
15
+
16
+ **Status:** ✓ shipped.
17
+
18
+ Two auth modes, both first-class (choose per deployment):
19
+
20
+ - **PAT / basic** — a Personal Access Token per subject. The default for Jira/Confluence **Data Center / Server**. Wire it via `credentialsResolver` (below).
21
+ - **OAuth 2.0 (3LO)** — the authorization-code flow for Atlassian **Cloud**, where the app acts on behalf of a consenting user. Use the toolkit ([OAuth 2.0 (3LO)](#oauth-20-3lo)) to obtain an access token, then feed it into the same `credentialsResolver`.
22
+
23
+ It also supports [inbound Jira/Confluence webhooks](#inbound-webhooks) (signature-verified) and writing comments to issues and pages.
24
+
25
+ ## Wiring
26
+
27
+ ```ts
28
+ // app.config.ts
29
+ import { Effect } from 'effect'
30
+ import { atlassianPlugin } from '@voltro/plugin-atlassian'
31
+
32
+ export default {
33
+ type: 'api' as const,
34
+ name: 'api',
35
+ plugins: [
36
+ atlassianPlugin({
37
+ credentialsResolver: ({ subject, store }) =>
38
+ Effect.gen(function* () {
39
+ const pat = yield* lookupPatForSubject(store, subject) // your schema
40
+ return {
41
+ baseUrl: process.env.JIRA_BASE_URL!,
42
+ token: pat,
43
+ patApplication: 'my-app',
44
+ patEnvironment: process.env.PAT_ENVIRONMENT!,
45
+ }
46
+ }),
47
+ // optional: cache, avatar proxy, retry/timeout policy — see below
48
+ }),
49
+ ],
50
+ }
51
+ ```
52
+
53
+ > **Never put the credential in `subject.metadata`.** The resolver used to take a
54
+ > bare `Subject`, so that was the only place to keep a per-user PAT — and from
55
+ > there it travels with the identity into everything that persists a Subject. A
56
+ > reporter found a working Jira PAT in plaintext in 12 of 23 rows of their
57
+ > `_voltro_audit_log` exactly that way. The `store` handle above exists so it
58
+ > never has to enter the Subject; [`connectionCredentials`](#connections) is
59
+ > better still, because then you do not hold the token at all.
60
+
61
+ The resolver returns `AtlassianCredentials`:
62
+
63
+ ```ts
64
+ interface AtlassianCredentials {
65
+ readonly baseUrl: string // the Atlassian site root
66
+ readonly token: string // the caller's Personal Access Token
67
+ readonly patApplication: string // → X-PAT-Application header
68
+ readonly patEnvironment: string // → X-PAT-Environment header
69
+ }
70
+ ```
71
+
72
+ Every method resolves the caller's PAT by calling `credentialsResolver({ subject: yield* SubjectService, store })`. The handler context satisfies that requirement, so you never thread credentials by hand. If a subject has no PAT on file, fail the resolver with a `JiraError` and the call surfaces it as a typed error.
73
+
74
+ ## Using it in a handler
75
+
76
+ ```ts
77
+ import { Effect } from 'effect'
78
+ import { JiraService, ConfluenceService } from '@voltro/plugin-atlassian'
79
+
80
+ export default (input) => Effect.gen(function* () {
81
+ const jira = yield* JiraService
82
+
83
+ const issues = yield* jira.searchIssues(
84
+ `project = ${input.project} AND sprint in openSprints()`,
85
+ )
86
+ const board = yield* jira.getGreenhopperBoard(input.boardId) // { issues, columns, swimlanes, raw }
87
+ const sprint = yield* jira.getActiveSprint(input.boardId) // SprintRef | null
88
+
89
+ return { count: issues.length, sprint }
90
+ })
91
+ ```
92
+
93
+ `JiraService` and `ConfluenceService` are both Effect-returning with typed errors, so a caller can branch on the failure tag:
94
+
95
+ ```ts
96
+ yield* jira.getIssue('ABC-1').pipe(
97
+ Effect.catchTag('JiraError', (err) =>
98
+ err.code === 'session_expired'
99
+ ? Effect.fail(reauthNeeded()) // PAT is stale — re-auth
100
+ : Effect.fail(err),
101
+ ),
102
+ )
103
+ ```
104
+
105
+ ## JiraService
106
+
107
+ | Method | Atlassian endpoint |
108
+ |---|---|
109
+ | `searchIssues(jql, options?)` | `POST /rest/api/2/search` — JQL, batched 100, follows pagination → all matches |
110
+ | `getGreenhopperBoard(boardId, options?)` | `GET /rest/greenhopper/1.0/xboard/work/allData.json` → `{ issues, columns, swimlanes, raw }` |
111
+ | `getBoardConfig(boardId)` | `GET /rest/greenhopper/1.0/rapidviewconfig/editmodel` |
112
+ | `getActiveSprint(boardId)` | `GET /rest/agile/1.0/board/{id}/sprint?state=active` → `SprintRef \| null` |
113
+ | `getSprintIssues(sprintId)` | `GET /rest/agile/1.0/sprint/{id}/issue` |
114
+ | `getSprintInfo(sprintId)` | `GET /rest/agile/1.0/sprint/{id}` |
115
+ | `getIssue(key, options?)` | `GET /rest/api/2/issue/{key}` (`expand` / `fields`) |
116
+ | `getIssueChangelog(key)` | `GET /rest/api/2/issue/{key}?expand=changelog` |
117
+ | `getComments(key)` | `GET /rest/api/2/issue/{key}/comment` |
118
+ | `addComment(key, body)` | `POST /rest/api/2/issue/{key}/comment` — add a comment |
119
+ | `getLinkedIssues(key)` | resolves an issue's `issuelinks` to the linked issues |
120
+ | `getUser({ key?, username? })` | `GET /rest/api/2/user` / `/rest/api/2/user/search` |
121
+ | `getMyself()` | `GET /rest/api/2/myself` |
122
+ | `getIssueTransitions(key)` | `GET /rest/api/2/issue/{key}/transitions` |
123
+ | `transitionIssue(key, transitionId, options?)` | `POST /rest/api/2/issue/{key}/transitions` |
124
+ | `createIssue(payload)` | `POST /rest/api/2/issue` |
125
+ | `updateIssue(key, payload)` | `PUT /rest/api/2/issue/{key}` |
126
+ | `getProject(key)` | `GET /rest/api/2/project/{key}` |
127
+ | `getFields()` | `GET /rest/api/2/field` |
128
+ | `getStatuses()` | `GET /rest/api/2/status` |
129
+ | `getFilters()` | `GET /rest/api/2/filter` |
130
+ | `getAvatarUrl(ownerId)` | builds the `/secure/useravatar?ownerId=…` URL |
131
+ | `getBoard(boardId)` | `GET /rest/agile/1.0/board/{id}` |
132
+ | `getBoardQuickFilters(boardId)` | `GET /rest/agile/1.0/board/{id}/quickfilter` (→ `.values`) |
133
+ | `getBoardFilterJql(boardId)` | `/board/{id}/configuration` → `/filter/{id}.jql` (board's saved-filter JQL, or `null`) |
134
+ | `assignIssue(key, assignee)` | `PUT /rest/api/2/issue/{key}/assignee` (`{ name }`; `null` unassigns) |
135
+ | `deleteIssue(key, { deleteSubtasks? })` | `DELETE /rest/api/2/issue/{key}` |
136
+ | `getBaseUrl()` | the configured base URL (no HTTP) |
137
+
138
+ ## ConfluenceService
139
+
140
+ Same PAT scheme and resilience as Jira.
141
+
142
+ | Method | Atlassian endpoint |
143
+ |---|---|
144
+ | `getContent(id, options?)` | `GET /rest/api/content/{id}?expand=body.view,version` |
145
+ | `searchContent(cql, options?)` | `GET /rest/api/content/search?cql=…&expand=version,space` |
146
+ | `getSpace(spaceKey)` | `GET /rest/api/space/{key}` |
147
+ | `createContent(payload)` | `POST /rest/api/content` |
148
+ | `updateContent(id, payload, { version })` | `PUT /rest/api/content/{id}` (version-bumped) |
149
+ | `getAttachments(contentId)` | `GET /rest/api/content/{id}/child/attachment` |
150
+ | `addComment(pageId, body)` | `POST /rest/api/content` — a `type: 'comment'` content (storage markup) anchored to the page |
151
+ | `downloadImage(url)` | PAT-gated binary fetch of a same-host embedded image → `{ bytes, contentType }` (SSRF-guarded) |
152
+
153
+ ## Resilience
154
+
155
+ Every call goes through one HTTP choke point that:
156
+
157
+ - **Retries** transient failures — `408 / 425 / 429 / 500 / 502 / 503 / 504` plus network blips / timeouts — with exponential backoff (default 4 attempts, 500 ms initial, capped at 10 s).
158
+ - **Honours `Retry-After`** on the response over the computed backoff (`honourRetryAfter: true` by default).
159
+ - **Times out** each request (default 20 s) and treats the timeout as transient.
160
+ - **SSRF-guards** every URL: the resolved host must equal the configured base host, so a crafted ref can't pivot the server-side fetch to an internal address.
161
+ - **Treats 401 as non-transient** `session_expired` — the stored PAT is stale, so re-auth rather than retry. It surfaces as a `JiraError` / `ConfluenceError` with `transient: false` and `code: 'session_expired'`.
162
+
163
+ Override the policy per instance:
164
+
165
+ ```ts
166
+ atlassianPlugin({
167
+ credentialsResolver,
168
+ policy: {
169
+ maxAttempts: 6,
170
+ initialDelayMs: 250,
171
+ maxDelayMs: 20_000,
172
+ timeoutMs: 30_000,
173
+ honourRetryAfter: true,
174
+ },
175
+ })
176
+ ```
177
+
178
+ ## OAuth 2.0 (3LO)
179
+
180
+ For Atlassian **Cloud**, where a PAT isn't available and the app acts on behalf of a consenting user, use the OAuth 2.0 three-legged-auth toolkit. It is an **alternative** to PAT auth, not a replacement — PAT/basic stays fully supported for Data Center / Server. The plugin holds **no** token storage of its own: the app persists the token set (the same seam PAT credentials use) and the toolkit handles the authorization-code exchange + refresh. The `clientSecret` and tokens are runtime values sourced from config/env, never logged, never bundled.
181
+
182
+ ```ts
183
+ import { Effect } from 'effect'
184
+ import {
185
+ buildAuthorizeUrl,
186
+ exchangeCode,
187
+ withFreshToken,
188
+ type OAuthConfig,
189
+ type OAuthTokens,
190
+ } from '@voltro/plugin-atlassian'
191
+
192
+ const oauth: OAuthConfig = {
193
+ clientId: process.env.ATLASSIAN_CLIENT_ID!,
194
+ clientSecret: process.env.ATLASSIAN_CLIENT_SECRET!, // secret — env/config only
195
+ redirectUri: 'https://app.example.com/oauth/atlassian/callback',
196
+ }
197
+
198
+ // 1. Redirect the user to consent. Store `state`; verify it on the callback.
199
+ // `offline_access` is required to receive a refresh token.
200
+ const authorizeUrl = buildAuthorizeUrl(oauth, {
201
+ scopes: ['read:jira-work', 'write:jira-work', 'offline_access'],
202
+ state: crypto.randomUUID(),
203
+ prompt: 'consent',
204
+ })
205
+
206
+ // 2. On the callback (`?code=…&state=…`), exchange the code + persist the set.
207
+ const onCallback = (code: string) => Effect.gen(function* () {
208
+ const tokens = yield* exchangeCode({ ...oauth, fetchImpl: globalThis.fetch as never }, code)
209
+ yield* persistTokensForSubject(tokens) // your store — { accessToken, refreshToken, expiresAt }
210
+ })
211
+
212
+ // 3. Before an OAuth call, get a still-valid token — refreshes near expiry.
213
+ const accessTokenFor = (stored: OAuthTokens) =>
214
+ withFreshToken(
215
+ { ...oauth, fetchImpl: globalThis.fetch as never },
216
+ stored,
217
+ (rotated) => persistTokensForSubject(rotated),
218
+ )
219
+ ```
220
+
221
+ Then feed the access token into the SAME `credentialsResolver` — the services send `Authorization: Bearer <accessToken>`, identical to the PAT path:
222
+
223
+ ```ts
224
+ atlassianPlugin({
225
+ credentialsResolver: (subject) => Effect.gen(function* () {
226
+ const stored = yield* loadTokensForSubject(subject) // your store
227
+ const fresh = yield* accessTokenFor(stored)
228
+ return {
229
+ baseUrl: `https://api.atlassian.com/ex/jira/${cloudIdForSubject(subject)}`,
230
+ token: fresh.accessToken,
231
+ patApplication: 'my-app',
232
+ patEnvironment: process.env.PAT_ENVIRONMENT!,
233
+ }
234
+ }),
235
+ })
236
+ ```
237
+
238
+ Exchange/refresh failures are a typed `AtlassianOAuthError` (`{ message, transient, status? }`) — `transient: true` only for a 5xx / network blip at the token endpoint; a missing refresh token surfaces as a non-transient re-consent signal. The `clientSecret` and tokens never appear in `message`.
239
+
240
+ ## Inbound webhooks
241
+
242
+ Receive Jira/Confluence webhook events over the framework's normal webhook seam. Atlassian signs the raw body with HMAC-SHA-256 and writes `X-Hub-Signature: sha256=<hex>` (the shared secret is configured on the webhook — env/config only, never logged).
243
+
244
+ **Recommended — the `*.webhook.tsx` provider path** (durable, dashboard-listed, auto-verified + de-duped by the framework). `atlassianWebhookProvider()` is a drop-in `@voltro/plugin-webhooks` provider preset:
245
+
246
+ ```ts
247
+ import { Schema } from 'effect'
248
+ import { defineIncomingWebhook } from '@voltro/plugin-webhooks'
249
+ import { atlassianWebhookProvider } from '@voltro/plugin-atlassian/webhook'
250
+
251
+ export default defineIncomingWebhook({
252
+ id: 'jira-events',
253
+ provider: atlassianWebhookProvider(), // HMAC-SHA-256 signature + idempotency
254
+ payload: Schema.Any, // narrow per event in the handler
255
+ handler: async (ctx) => {
256
+ // Signature already verified by the framework; switch on the event type.
257
+ switch ((ctx.body as { webhookEvent?: string }).webhookEvent) {
258
+ case 'jira:issue_updated': break
259
+ case 'comment_created': break
260
+ }
261
+ },
262
+ })
263
+ ```
264
+
265
+ The preset extracts the event type from the body's `webhookEvent` field and the idempotency key from the `X-Atlassian-Webhook-Identifier` header (falling back to `<event>:<timestamp>`).
266
+
267
+ **Raw-route path** — if you wire the webhook onto a `PluginHttpRoute` (or any HTTP surface) yourself, verify + parse with the standalone primitives:
268
+
269
+ ```ts
270
+ import { verifyWebhookSignature, parseWebhookEnvelope } from '@voltro/plugin-atlassian/webhook'
271
+
272
+ const handle = (rawBody: Uint8Array, headers: Record<string, string>) => {
273
+ verifyWebhookSignature({ rawBody, headers, secret: process.env.ATLASSIAN_WEBHOOK_SECRET! })
274
+ const event = parseWebhookEnvelope(rawBody) // { webhookEvent, issue?, comment?, page?, raw }
275
+ // dispatch on event.webhookEvent
276
+ }
277
+ ```
278
+
279
+ Both reject failures with a typed `AtlassianWebhookError` — `reason: 'signature'` (missing/invalid signature → map to 401) or `reason: 'payload'` (bad body → 400). Verification is constant-time.
280
+
281
+ ## Avatar proxy
282
+
283
+ Jira user avatars require the PAT, so the browser can't fetch them directly. The proxy resolves the avatar server-side and serves the **bytes** — the artefact the browser gets is credential-free, which is the whole point: the PAT is spent on the fetch and never reaches the client.
284
+
285
+ The route serves bytes at `/_voltro/atlassian/avatar/:ownerId` — fetched server-side with binary headers (no `Accept`, so Jira returns the raw image), SSRF-guarded to the configured host, `image/*`-only (never proxies an HTML error page), with a 1-year `immutable` cache (a Jira avatar for an `ownerId` never changes).
286
+
287
+ `avatar` is a **discriminated union on a required `mode`**. The two modes are not interchangeable, and a config file must not leave which one is in play to inference:
288
+
289
+ | `mode` | Whose credential fetches | Route access | Cache key |
290
+ |---|---|---|---|
291
+ | `'service'` | one shared service token | public | the avatar ref alone |
292
+ | `'perUser'` | the **viewing** subject's own | authenticated only | viewer + avatar ref |
293
+
294
+ ### `mode: 'service'`
295
+
296
+ One shared credential fetches every avatar and the route is public. Cheap, and every caller sees the same bytes:
297
+
298
+ ```ts
299
+ atlassianPlugin({
300
+ credentialsResolver,
301
+ avatar: {
302
+ mode: 'service',
303
+ resolveCredentials: () => ({
304
+ baseUrl: process.env.JIRA_BASE_URL!,
305
+ token: process.env.JIRA_SERVICE_PAT!, // dedicated read-only token
306
+ patApplication: 'my-app',
307
+ patEnvironment: process.env.PAT_ENVIRONMENT!,
308
+ }),
309
+ // pathPrefix defaults to '/_voltro/atlassian/avatar'
310
+ },
311
+ })
312
+ ```
313
+
314
+ ### `mode: 'perUser'`
315
+
316
+ Every fetch runs with the **viewing** subject's own credential, resolved per request — for deployments where a service PAT must not exist at all:
317
+
318
+ ```ts
319
+ atlassianPlugin({
320
+ credentialsResolver,
321
+ avatar: {
322
+ mode: 'perUser',
323
+ // The framework does NOT authenticate plugin HTTP routes, so the app
324
+ // resolves the acting subject itself — typically from its session cookie.
325
+ resolveSubject: (req) => resolveSubjectFromCookie(req.headers['cookie']),
326
+ // That subject's own PAT. Throw when they have none — the route answers 403.
327
+ resolveCredentials: (subject) => loadPatForSubject(subject),
328
+ },
329
+ })
330
+ ```
331
+
332
+ `resolveSubject` is app-supplied because `PluginHttpRouteRequest` carries **no subject**: a plugin HTTP route is not part of the rpc pipeline, so nothing upstream has authenticated the caller. Verify the session yourself (`readCookie` + `verifySession` from `@voltro/protocol/session`) inside your own server-only module — that subpath must not be imported from a browser-reachable file. Return `null` for an unauthenticated caller.
333
+
334
+ Three properties of this mode are load-bearing:
335
+
336
+ - **Anonymous is refused outright.** `null`, an `anonymous` subject, or a subject with no `id` gets a `401` with **no upstream fetch and no cache read** — the identity is settled before anything else happens.
337
+ - **There is no service fallback.** A subject with no PAT on file gets a `403`. Falling back to a shared credential is precisely what a deployment choosing this mode cannot do.
338
+ - **The cache key is the VIEWER, never the avatar's owner.** A per-user entry keys on `type` + `id` + `tenantId` of the subject whose credential paid for the fetch, plus the avatar ref. Keying by the owner alone would be a cross-user read: user A's fetch would populate an entry served straight to user B without B's PAT ever being checked against Jira. The cost is a lower hit rate — one entry per (viewer, avatar) pair — and that is the correct trade, because a shared entry *is* the cross-user read. (`service` mode keys by the avatar ref alone, which is correct there: one credential authorised the fetch and every caller of the public route is entitled to exactly those bytes.)
339
+
340
+ Pass a `cache` (`{ store, ttlMs? }`) in either mode; the stored value is content-type + base64 bytes, so a hit costs no upstream call and leaks no token.
341
+
342
+ Upgrading from a config without `mode`? `voltro update` ships a codemod for it — the previous shape was service-only by construction (a zero-argument `resolveCredentials`, public route), so it inserts `mode: 'service'` at every `atlassianPlugin({ avatar: … })` site. Opting into `perUser` is a deliberate act afterwards: it needs a `resolveSubject` only your app can write.
343
+
344
+ ## Caching
345
+
346
+ Pass a `@voltro/cache` store to memoise read calls per tenant. Keys are namespaced `atlassian:<namespace>:<tenant>:<key>`, so one org never reads another's cached board:
347
+
348
+ ```ts
349
+ import { atlassianPlugin } from '@voltro/plugin-atlassian'
350
+
351
+ atlassianPlugin({
352
+ credentialsResolver,
353
+ cache: {
354
+ store, // a @voltro/cache CacheStore
355
+ ttlMs: { boardConfig: 60_000, project: 30_000 },
356
+ },
357
+ })
358
+ ```
359
+
360
+ Only reference-data reads are cached — the four namespaces `boardConfig` (`getBoardConfig`), `project` (`getProject`), `field` (`getFields`), `status` (`getStatuses`). Issue/board *content* is never cached (it changes too often to serve stale). A missing namespace TTL falls back to the store default. Caching is best-effort: a cache read/write failure never fails the call — it falls through to the live request. With no `cache` option, every call is a direct pass-through.
361
+
362
+ ## Multiple sites
363
+
364
+ Run two instances against two Atlassian sites by naming them:
365
+
366
+ ```ts
367
+ plugins: [
368
+ atlassianPlugin({ name: 'eu', credentialsResolver: euResolver }),
369
+ atlassianPlugin({ name: 'us', credentialsResolver: usResolver }),
370
+ ]
371
+ ```
372
+
373
+ ## Anti-patterns
374
+
375
+ - **Hard-coding a single shared PAT.** Resolve per subject so each caller acts as themselves and a stale token surfaces as `session_expired` for that user only.
376
+ - **Fetching Jira avatars from the browser.** They need the PAT; use the avatar proxy and let the browser hit `/_voltro/atlassian/avatar/:ownerId`.
377
+ - **Retrying a 401 yourself.** It's non-transient by design — re-auth the subject instead.
378
+ - **Caching writes.** Only read calls (board config, project / field / status metadata) belong in the cache; mutations always go live.