| 1 | import * as Axiom from "@distilled.cloud/axiom"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import { deepEqual, isResolved } from "../Diff.ts"; |
| 5 | import * as Provider from "../Provider.ts"; |
| 6 | import { Resource } from "../Resource.ts"; |
| 7 | import type { Providers } from "./Providers.ts"; |
| 8 | |
| 9 | export type ApiTokenProps = Omit<Axiom.CreateAPITokenRequest, never>; |
| 10 | |
| 11 | export 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 | */ |
| 73 | export const ApiToken = Resource<ApiToken>("Axiom.ApiToken"); |
| 74 | |
| 75 | export 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 | ); |