@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.
- package/CHANGELOG.md +163 -0
- package/dist/apiBuild-BOqL1dlg.js +2 -0
- package/dist/{apiBuild-DmkzRyv7.js → apiBuild-CLPt-pX6.js} +2 -2
- package/dist/appGraph-CvQCte0z.js +2 -0
- package/dist/appGraph-gQ_6GkQQ.js +110 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-ClRvH_SN.js +2 -0
- package/dist/{checkCommand-C4konUy9.js → checkCommand-D7r6_pk7.js} +439 -530
- package/dist/{commands-BkJft9K2.js → commands-CNVVv03h.js} +1941 -1880
- package/dist/{dbCommand-qn__kLEt.js → dbCommand-BH8GAYuf.js} +76 -19
- package/dist/dbCommand-BwQ_Jl98.js +2 -0
- package/dist/{dev-DXhhZ7-f.js → dev-DOZVMVfi.js} +1 -1
- package/dist/{dev-C_NPcLDp.js → dev-DdQEn2Ia.js} +1986 -1967
- package/dist/{frameworkTableAssembly-Cpgn2e88.js → frameworkTableAssembly-BwIrO5nv.js} +122 -103
- package/dist/frameworkTableAssembly-D-EebUQX.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-CkB2J0Zn.js → inspectMetrics-BeEAnjqw.js} +774 -527
- package/dist/{sdkgen-wsWLrq4l.js → sdkgen-v9WV8zRi.js} +1 -1
- package/dist/{serveCommand-HtI4X5kj.js → serveCommand-KsKXXWn4.js} +294 -293
- package/dist/serveEntry.js +2 -2
- package/dist/{start-egkbpugc.js → start-CI2QdKy9.js} +351 -346
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +43 -43
- package/templates/agent-docs/_index.md +43 -43
- package/templates/agent-docs/_manifest.json +84 -42
- package/templates/agent-docs/cli.md +4 -2
- package/templates/agent-docs/database/migrations.md +14 -1
- package/templates/agent-docs/internationalization.md +54 -3
- package/templates/agent-docs/plugins/ai-flows.md +148 -0
- package/templates/agent-docs/plugins/analytics-postgres.md +90 -0
- package/templates/agent-docs/plugins/atlassian.md +378 -0
- package/templates/agent-docs/plugins/audit.md +324 -0
- package/templates/agent-docs/plugins/auth-auth0.md +92 -0
- package/templates/agent-docs/plugins/auth-clerk.md +90 -0
- package/templates/agent-docs/plugins/auth-kinde.md +88 -0
- package/templates/agent-docs/plugins/auth-oidc.md +95 -0
- package/templates/agent-docs/plugins/auth-supabase.md +103 -0
- package/templates/agent-docs/plugins/auth-workos.md +105 -0
- package/templates/agent-docs/plugins/auth.md +227 -0
- package/templates/agent-docs/plugins/billing.md +324 -0
- package/templates/agent-docs/plugins/broadcast.md +144 -0
- package/templates/agent-docs/plugins/cdc-out.md +116 -0
- package/templates/agent-docs/plugins/clickhouse.md +91 -0
- package/templates/agent-docs/plugins/datadog.md +105 -0
- package/templates/agent-docs/plugins/deactivation.md +114 -0
- package/templates/agent-docs/plugins/duckdb.md +82 -0
- package/templates/agent-docs/plugins/flags.md +146 -0
- package/templates/agent-docs/plugins/governance.md +143 -0
- package/templates/agent-docs/plugins/licensing.md +74 -0
- package/templates/agent-docs/plugins/logship.md +56 -0
- package/templates/agent-docs/plugins/mail.md +334 -0
- package/templates/agent-docs/plugins/moderation.md +65 -0
- package/templates/agent-docs/plugins/multitenancy.md +99 -0
- package/templates/agent-docs/plugins/notifications.md +252 -0
- package/templates/agent-docs/plugins/openapi.md +94 -0
- package/templates/agent-docs/plugins/postgis.md +140 -0
- package/templates/agent-docs/plugins/posthog.md +91 -0
- package/templates/agent-docs/plugins/presence.md +115 -0
- package/templates/agent-docs/plugins/prometheus.md +124 -0
- package/templates/agent-docs/plugins/ratelimit.md +274 -0
- package/templates/agent-docs/plugins/rbac.md +270 -0
- package/templates/agent-docs/plugins/scim.md +63 -0
- package/templates/agent-docs/plugins/search.md +116 -0
- package/templates/agent-docs/plugins/sentry.md +192 -0
- package/templates/agent-docs/plugins/soft-delete.md +72 -0
- package/templates/agent-docs/plugins/sso-saml.md +132 -0
- package/templates/agent-docs/plugins/storage.md +709 -0
- package/templates/agent-docs/plugins/tinybird.md +92 -0
- package/templates/agent-docs/plugins/versioning.md +226 -0
- package/templates/agent-docs/plugins/webhooks.md +585 -0
- package/templates/agent-docs/security.md +12 -1
- package/templates/agent-docs/whats-new.md +81 -37
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +10 -10
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/package.json +9 -9
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +2 -2
- package/dist/apiBuild-Ca6Sq5jy.js +0 -2
- package/dist/checkCommand-DHD0aBeR.js +0 -2
- package/dist/dbCommand-wfGi3FgG.js +0 -2
- 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', 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.
|