@cueplusplus/id-admin
v0.3.1
Published
CUE++ ID — server-only admin SDK (service-role PostgREST + auth.admin + security-definer RPCs) for the admin console.
Readme
@cueplusplus/id-admin
Server-only admin SDK for CUE++ ID.
It wraps a service-role Supabase client and exposes the five management
surfaces the admin console needs — invites, users, OAuth clients, the audit log
and webhooks — writing admin_audit_log rows for the mutations it performs
directly and delegating the rest to security-definer RPCs.
The service-role key bypasses RLS. This package imports server-only, so a
bundler will fail the build if it ever reaches a client component.
Install
pnpm add @cueplusplus/id-adminUsage
import { createCueIdAdmin } from '@cueplusplus/id-admin'
const admin = createCueIdAdmin({
url: process.env.SUPABASE_URL!,
serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY!,
})
// Invite-only signup: create returns the RAW token — build the accept link
// yourself; only its sha256 hash is stored.
const { data, error } = await admin.invites.create({ email: '[email protected]', role: 'member' })
// `data.token` is a BEARER CREDENTIAL: it creates an account at the chosen role.
// Hand it to the mailer, never to a log — a log line lands in aggregation.
// NOTE: `data` and `error` can BOTH be set. If the invite committed but its
// audit row did not, you still get the token (it exists nowhere else) plus the
// error to raise. Check `error`; do not infer failure from `data === null`.
if (data) {
await mailer.sendInvite(data.id, `https://id.cueplusplus.com/accept?token=${data.token}`)
}
if (error) reportToOncall(error)
// `token` is a NON-ENUMERABLE ACCESSOR. `data.token` and `const { token } =
// data` work; every blanket serializer omits it — JSON.stringify, %j,
// console.log, console.log('%o', …), Object.keys/entries, spread,
// Object.assign, structuredClone — so `logger.error({ result })` cannot put a
// live bearer credential into log aggregation. (`%o` prints `[token]: [Getter]`:
// it reveals the property exists without invoking it.)
// The trade-off: `{ ...data }` does not copy it — read `data.token` directly.
await admin.users.setRole({ userId, role: 'admin', actorId })
await admin.users.revokeSessions({ userId })
// OAuth clients go through Supabase Auth's own admin API — `clientId` is the
// ONLY identifier (there is no separate row id), and `delete` is a soft delete.
const { data: client } = await admin.clients.create({
name: 'Dashboard',
redirectUris: ['https://dash.cueplusplus.com/auth/callback'],
actorId,
})
// `client.clientSecret` is shown ONCE — Supabase keeps only its hash. Like the
// invite token it is a non-enumerable accessor: read it directly, never via a
// spread or a log line. Defaults to `token_endpoint_auth_method =
// client_secret_basic` (spec §7.3), which is @cueplusplus/id's default.
// A PKCE-only PUBLIC client (mobile, CLI, SPA): no secret is minted, and
// `clientSecret: null` is then the SUCCESSFUL outcome. There is no
// `client_type` to pass — the server infers it from the auth method.
await admin.clients.create({
name: 'CUE++ CLI',
redirectUris: ['http://127.0.0.1:7777/callback'],
tokenEndpointAuthMethod: 'none',
actorId,
})
await admin.clients.update({ clientId: client!.clientId, redirectUris: [...], actorId })
// The ONLY repair path for a leaked or lost secret — nothing can re-show the
// old one. DESTRUCTIVE: the previous secret stops working immediately, so every
// consumer holding it fails token exchange until it is updated.
const { data: rotated } = await admin.clients.regenerateSecret({ clientId: client!.clientId, actorId })
// rotated.clientSecret — again, once, and again a non-enumerable accessor.
await admin.clients.delete({ clientId: client!.clientId, actorId })
// A soft delete, and what that means for ACCESS is deliberate (issue #51):
// once a client is retired, `effective_access` stops resolving grants and
// client-group memberships that point at it, and its introspection credential
// stops verifying — so `/api/access/token` will not mint for it. Both are
// enforced in the derivation, not at this call, so they hold for clients
// deleted before that change and cannot be bypassed by a different code path.
// The rows themselves survive: the grant, the membership and the
// `access_api_clients` row are all still there for history.
// `list()` PAGES and says whether it reached the end. `rows` is a prefix when
// `complete` is false — GoTrue's list endpoint pages and auth-js's cursor
// cannot be trusted past page 9, so a caller must not render `rows` as
// "every client" without checking.
const { data: page } = await admin.clients.list()
page!.rows // ClientRow[] page!.complete // boolean
// `audit.list()` is a SCRIPT helper, like `users.list()` — one page, newest
// first, `complete` false when the page filled, `nextBefore` to walk further.
// A console reads public.admin_audit_log directly; see the type docs for why.
const { data: page } = await admin.audit.list({ action: 'invite.create', limit: 50 })
const { rows, complete, nextBefore } = page ?? { rows: [], complete: false, nextBefore: null }Webhooks
// Register an endpoint. The signing secret is returned ONCE and exists nowhere
// else afterwards — it lives in a table no role holds any privilege on, and
// Postgres (not this process) computes every HMAC. Rotation is the only repair.
const { data: hook } = await admin.webhooks.create({
url: 'https://example.com/hooks/cue-id',
eventNames: ['login', 'invite.accepted', 'invite.declined'],
actorId,
})
hook!.signingSecret // read DIRECTLY — a non-enumerable accessor, like the invite token
// Every mutation writes its admin_audit_log row inside the SAME transaction as
// the mutation (the RPC does it), so unlike invites/clients there is no state
// where the change committed and the audit row did not.
await admin.webhooks.setEnabled({ id: hook!.id, enabled: false, reason: 'consumer migration', actorId })
await admin.webhooks.rotateSecret({ id: hook!.id, actorId }) // DESTRUCTIVE, no overlap window
await admin.webhooks.delete({ id: hook!.id, actorId }) // soft: history survives, the key does not
// The delivery log. `complete` is false when the page filled — never render
// `rows` as "everything", and never render a FAILED read as an empty log.
const { data: log, error } = await admin.webhooks.listDeliveries({ status: 'dead', limit: 50 })
if (error) throw error // `data: null` here means the read failed, not "nothing was sent"
await admin.webhooks.replayDelivery({ id: log!.rows[0]!.id, actorId })Delivery is at-least-once. Consumers must de-duplicate on the Cue-Event-Id
header (also id in the body), verify Cue-Signature in constant time, and
reject a timestamp outside a few minutes. The full consumer contract is in
docs/runbooks/webhooks.md.
dispatchWebhooks() is the delivery loop itself — claim, POST, settle — with
newClient, fetchImpl and now injected so it can be driven in a test. It
never sleeps on a consumer's Retry-After, never follows a redirect, bounds
each attempt with an AbortController and bounds the whole invocation with a
wall-clock budget.
users.list() is the same kind of script helper, and it takes no search
parameter — GoTrue has no server-side user search, so any query here could
only ever filter the page it happened to fetch. It was removed rather than
documented, so the wrong answer is unreachable. Build a directory from
public.profiles instead (exact count, server-side ilike, stable ordering).
Every call returns { data, error } — error is an AdminError
({ message, code?, status? }); nothing throws.
Timeouts
Every client carries a total request deadline shared by all of its calls —
CUE_ID_ADMIN_TIMEOUT_MS, default 5000ms — and postgrest-js's 503 retry ladder
is off. Both are deliberate: postgrest-js honours Retry-After with no upper
bound, so a single 503 Retry-After: 120 would park each of three retries for two
minutes, and a deadline cannot interrupt its backoff sleep. Values at or above
the 7s retry ladder are refused.
License
MIT — see LICENSE.
