1import * as Axiom from "@distilled.cloud/axiom";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import { deepEqual, isResolved } from "../Diff.ts";
5import * as Provider from "../Provider.ts";
6import { Resource } from "../Resource.ts";
7import type { Providers } from "./Providers.ts";
8
9export type ApiTokenProps = Omit<Axiom.CreateAPITokenRequest, never>;
10
11export type ApiToken = Resource<
12 "Axiom.ApiToken",
13 ApiTokenProps,
14 Omit<Axiom.CreateAPITokenResponse, "token"> & {
15 /**
16 * The bearer token. Returned only by `create` (and `regenerate`); Axiom
17 * does not return it on subsequent reads. Persisted in resource state via
18 * `Redacted` — handle with care.
19 */
20 token: Redacted.Redacted<string>;
21 },
22 never,
23 Providers
24>;
25
26/**
27 * An Axiom API token — a scoped bearer token used to authenticate API
28 * requests (ingest, query, admin). Capabilities are pinned at creation time;
29 * changing any field triggers a **replacement** because Axiom does not
30 * expose an update endpoint.
31 *
32 * The raw token value is returned only by `create`. After that, Axiom
33 * never echoes it back, so it is captured into `output.token` (as a
34 * {@link Redacted}) on initial create and persisted in resource state.
35 * Treat resource state as sensitive — anyone with read access can recover
36 * the token. Pair with a secret store for downstream consumption.
37 * @see https://axiom.co/docs/reference/tokens
38 *
39 * ### Creating an API Token
40 * **Example:** Ingest-only token scoped to one dataset
41 * ```typescript
42 * const ingest = yield* Axiom.ApiToken("ingest", {
43 * name: "prod-ingest",
44 * description: "OTEL collector ingest",
45 * datasetCapabilities: {
46 * "my-app-traces": { ingest: ["create"] },
47 * },
48 * });
49 * ```
50 *
51 * **Example:** Read-only query token
52 * ```typescript
53 * yield* Axiom.ApiToken("query", {
54 * name: "grafana-reader",
55 * datasetCapabilities: {
56 * "my-app-traces": { query: ["read"] },
57 * "my-app-logs": { query: ["read"] },
58 * },
59 * });
60 * ```
61 *
62 * ### Consuming the Token
63 * **Example:** Forward the token via Cloudflare Secrets
64 * ```typescript
65 * const secret = yield* Cloudflare.SecretsStore.Secret("axiom-token", {
66 * value: ingest.token,
67 * });
68 * ```
69 *
70 * @resource
71 * @product API Token
72 */
73export const ApiToken = Resource<ApiToken>("Axiom.ApiToken");
74
75export const ApiTokenProvider = () =>
76 Provider.effect(
77 ApiToken,
78 Effect.gen(function* () {
79 const create = yield* Axiom.createAPIToken;
80 const get = yield* Axiom.getAPIToken;
81 const del = yield* Axiom.deleteAPIToken;
82 const listTokens = yield* Axiom.getAPITokens;
83
84 return {
85 stables: ["id", "token"],
86 // Enumerate every API token in the org. Axiom exposes a single
87 // account-wide `GET /v2/tokens` collection op (no pagination), so we
88 // fetch it once and hydrate each row into the exact `read` Attributes
89 // shape. The bearer secret is returned by Axiom only at creation and is
90 // never echoed back on enumeration, so — matching `read`, which sources
91 // the secret from cached state — we surface an empty Redacted token
92 // here rather than the real value.
93 list: () =>
94 Effect.gen(function* () {
95 const tokens = yield* listTokens({});
96 return tokens.map((token) => ({
97 ...token,
98 token: Redacted.make(""),
99 }));
100 }),
101 diff: Effect.fn(function* ({ olds, news, output }) {
102 if (!isResolved(news)) return undefined;
103 // First create — let the engine create normally.
104 if (output == null) return undefined;
105 // Axiom has no update endpoint for tokens, so any actual change
106 // to the inputs forces a replacement (which mints a new bearer
107 // value). If nothing changed, fall through to `noop` so we don't
108 // needlessly rotate the token on every deploy.
109 if (deepEqual(olds, news)) return undefined;
110 return { action: "replace" } as const;
111 }),
112 reconcile: Effect.fn(function* ({ news, output }) {
113 // Observe — Axiom does not expose an update endpoint for tokens,
114 // so any actual change to the inputs is forced to a replacement
115 // by `diff` above. That means by the time `reconcile` runs we
116 // are either (a) creating fresh, or (b) re-reconciling identical
117 // inputs against an existing token.
118 if (output !== undefined) {
119 // Identical inputs — Axiom does not echo the bearer back, so
120 // the cached `output` (with the persisted Redacted token) is
121 // the authoritative current state.
122 return output;
123 }
124
125 // Ensure — mint a new token. Axiom returns the bearer exactly
126 // once; capture it into Redacted state for downstream consumers.
127 const result = yield* create(news);
128 if (!result.token) {
129 return yield* Effect.die(
130 new Error("Axiom did not return a token on create"),
131 );
132 }
133 return {
134 ...result,
135 token: Redacted.make(result.token),
136 };
137 }),
138 delete: Effect.fn(function* ({ output }) {
139 yield* del({ id: output.id }).pipe(
140 Effect.catchTag("NotFound", () => Effect.void),
141 );
142 }),
143 read: Effect.fn(function* ({ output }) {
144 if (!output?.id) return undefined;
145 return yield* get({ id: output.id }).pipe(
146 Effect.map((current) => ({ ...current, token: output.token })),
147 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
148 );
149 }),
150 };
151 }),
152 );