init: initial commit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
dist/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
134
README.md
Normal file
134
README.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# @crema/arcadia-client
|
||||||
|
|
||||||
|
Typed HTTP client + React bindings for the [arcadia](https://git.sky-ai.com/CremaUIStudio/arcadia-app) Phoenix API. Wraps the OpenAPI-spec'd surface at `/api/v1` with auth (Bearer JWT and/or service-account API key), tenant context (`X-Tenant-ID`), idempotency keys, error normalization, and rate-limit-aware retry.
|
||||||
|
|
||||||
|
The client is **stateless about how the JWT is obtained** — callers pass `getToken` so token refresh and storage stay app-owned.
|
||||||
|
|
||||||
|
## Public API
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
createArcadiaClient,
|
||||||
|
ArcadiaProvider,
|
||||||
|
useArcadia,
|
||||||
|
useArcadiaClient,
|
||||||
|
ArcadiaError,
|
||||||
|
} from "@crema/arcadia-client";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage in a Crema app
|
||||||
|
|
||||||
|
In `app/root.tsx`, wrap providers:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ArcadiaProvider } from "@crema/arcadia-client";
|
||||||
|
|
||||||
|
<ArcadiaProvider
|
||||||
|
baseUrl={import.meta.env.VITE_ARCADIA_URL}
|
||||||
|
initialTenantId={tenantId}
|
||||||
|
getToken={() => sessionStorage.getItem("arcadia_token")}
|
||||||
|
onUnauthorized={() => navigate("/login")}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ArcadiaProvider>;
|
||||||
|
```
|
||||||
|
|
||||||
|
In any component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useArcadiaClient, ArcadiaError } from "@crema/arcadia-client";
|
||||||
|
|
||||||
|
function ResourceList() {
|
||||||
|
const arcadia = useArcadiaClient();
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
arcadia
|
||||||
|
.GET<{ data: Resource[] }>("/api/v1/digital_objects", { params: { page: 1 } })
|
||||||
|
.then((res) => setItems(res.data))
|
||||||
|
.catch((err: ArcadiaError) => {
|
||||||
|
if (err.isRateLimited) showToast("Too many requests, slow down.");
|
||||||
|
});
|
||||||
|
}, [arcadia]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Generated types
|
||||||
|
|
||||||
|
Endpoint request/response types come from arcadia's live OpenAPI spec. Regenerate with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ARCADIA_OPENAPI_URL=http://localhost:4000/api/openapi \
|
||||||
|
node ../lib-arcadia-client/scripts/sync-spec.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `openapi-typescript` in the consuming app's devDeps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i -D openapi-typescript
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated file lives at `src/generated/openapi.d.ts`. Until it's been generated at least once, the file is a stub and only the hand-written types in `src/types.ts` are useful.
|
||||||
|
|
||||||
|
## Two surfaces: generic + typed
|
||||||
|
|
||||||
|
The client gives you both a generic-string API and a fully typed (OpenAPI-driven) API. They share the same auth/retry/error plumbing.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const arcadia = useArcadiaClient();
|
||||||
|
|
||||||
|
// Generic — accepts any string path. Use when the spec is incomplete or
|
||||||
|
// when calling endpoints outside the spec.
|
||||||
|
const res = await arcadia.GET<{ data: Resource[] }>(
|
||||||
|
"/api/v1/digital_objects",
|
||||||
|
{ params: { page: 1 } },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Typed — paths, params, and responses inferred from the generated spec.
|
||||||
|
// Throws ArcadiaError on non-2xx.
|
||||||
|
const { data } = await arcadia.typed.GET("/api/v1/digital_objects", {
|
||||||
|
params: { query: { page: 1 } },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Realtime
|
||||||
|
|
||||||
|
Phoenix Channels at `/socket/tenant`, opt-in via the provider. The socket auto-connects when `enableRealtime` is on, joins `tenant:<id>` (and optionally `tenant:<id>:user:<userId>`), and disconnects on unmount.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ArcadiaProvider
|
||||||
|
baseUrl={ARCADIA_URL}
|
||||||
|
initialTenantId={tenantId}
|
||||||
|
userId={userId}
|
||||||
|
enableRealtime
|
||||||
|
getToken={() => sessionStorage.getItem("arcadia_access_token")}
|
||||||
|
>
|
||||||
|
…
|
||||||
|
</ArcadiaProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useArcadiaSubscription } from "@crema/arcadia-client";
|
||||||
|
|
||||||
|
function NotificationToasts() {
|
||||||
|
useArcadiaSubscription("notification", (n) => {
|
||||||
|
toast(n.title, n.body);
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Known events on `TenantEventMap`: `notification`, `digital_object`, `announcement`, `status_update`, `event`. The map is open-ended — apps can subscribe to any string event arcadia emits; payload type defaults to `Record<string, unknown>`.
|
||||||
|
|
||||||
|
For user-scoped events (those filtered to the current user), pass `{ scope: "user" }` and ensure `userId` was provided to the provider.
|
||||||
|
|
||||||
|
## What's not in here yet
|
||||||
|
|
||||||
|
- **TanStack Query helpers** — opt-in. Vibespace doesn't use Query today; we can layer it via a sub-export later.
|
||||||
|
- **Token refresh helper** — the client surfaces 401 via `onUnauthorized`; the app decides whether to refresh + retry. A reference refresh helper may land later.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Inline imports only — no own `package.json` (lib lives by the consuming app's deps).
|
||||||
|
- Path-aliased into apps via `tsconfig.json` `paths`: `@crema/arcadia-client` → `../lib-arcadia-client/src/index.tsx`.
|
||||||
|
- Tailwind doesn't scan this lib — no UI; nothing to scan.
|
||||||
99
scripts/sync-spec.mjs
Normal file
99
scripts/sync-spec.mjs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Fetches the arcadia OpenAPI spec and regenerates ../src/generated/openapi.d.ts.
|
||||||
|
//
|
||||||
|
// Usage (from a consuming app, with arcadia reachable):
|
||||||
|
// node ../lib-arcadia-client/scripts/sync-spec.mjs
|
||||||
|
//
|
||||||
|
// Configurable via env:
|
||||||
|
// ARCADIA_OPENAPI_URL default: http://localhost:4000/api/openapi
|
||||||
|
// ARCADIA_BEARER_TOKEN optional, sent as Authorization if the spec route
|
||||||
|
// is gated in your deployment
|
||||||
|
//
|
||||||
|
// Requires `openapi-typescript` to be available (in the consuming app's
|
||||||
|
// node_modules, or globally). Run from a consuming app so the dep resolves:
|
||||||
|
// npm i -D openapi-typescript
|
||||||
|
|
||||||
|
import { writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const outDir = resolve(here, "..", "src", "generated");
|
||||||
|
const outFile = resolve(outDir, "openapi.d.ts");
|
||||||
|
const specUrl = process.env.ARCADIA_OPENAPI_URL ?? "http://localhost:4000/api/openapi";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`→ fetching ${specUrl}`);
|
||||||
|
const headers = {};
|
||||||
|
if (process.env.ARCADIA_BEARER_TOKEN) {
|
||||||
|
headers["Authorization"] = `Bearer ${process.env.ARCADIA_BEARER_TOKEN}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(specUrl, { headers });
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`spec fetch failed: ${res.status} ${res.statusText}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const spec = await res.json();
|
||||||
|
|
||||||
|
// Resolve openapi-typescript from the consuming app's node_modules, since
|
||||||
|
// the lib has no package.json of its own.
|
||||||
|
let openapiTs;
|
||||||
|
let astToString;
|
||||||
|
try {
|
||||||
|
const requireFromCwd = createRequire(resolve(process.cwd(), "package.json"));
|
||||||
|
const resolved = requireFromCwd.resolve("openapi-typescript");
|
||||||
|
const mod = await import(pathToFileURL(resolved).href);
|
||||||
|
// Handle both ESM (mod.default is the function) and CJS-via-import
|
||||||
|
// (mod.default is the namespace, mod.default.default is the function).
|
||||||
|
openapiTs = typeof mod.default === "function" ? mod.default : mod.default?.default;
|
||||||
|
astToString = mod.astToString ?? mod.default?.astToString;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`openapi-typescript could not be resolved from ${process.cwd()}.\n` +
|
||||||
|
`Run from your consuming app's directory after installing it:\n` +
|
||||||
|
` cd <app> && npm i -D openapi-typescript\n` +
|
||||||
|
`Original error: ${err.message}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrub malformed operation entries. Arcadia's spec contains some
|
||||||
|
// endpoints whose operation value is the bare string "ok" (a controller
|
||||||
|
// placeholder that never got replaced with a real OperationObject).
|
||||||
|
// openapi-typescript can't transform them — strip them and report so
|
||||||
|
// the generation succeeds for the rest. Fix is on the arcadia side.
|
||||||
|
const skipped = [];
|
||||||
|
if (spec.paths) {
|
||||||
|
for (const [path, item] of Object.entries(spec.paths)) {
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
for (const [verb, op] of Object.entries(item)) {
|
||||||
|
if (typeof op !== "object" || op === null) {
|
||||||
|
skipped.push(`${verb.toUpperCase()} ${path}`);
|
||||||
|
delete item[verb];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (skipped.length) {
|
||||||
|
console.warn(`⚠ skipped ${skipped.length} malformed operations (arcadia spec bug):`);
|
||||||
|
for (const s of skipped.slice(0, 10)) console.warn(` - ${s}`);
|
||||||
|
if (skipped.length > 10) console.warn(` …and ${skipped.length - 10} more`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`→ generating types`);
|
||||||
|
const ast = await openapiTs(spec);
|
||||||
|
const dts = astToString(ast);
|
||||||
|
await mkdir(outDir, { recursive: true });
|
||||||
|
const banner =
|
||||||
|
"// AUTO-GENERATED — do not edit by hand. Regenerate with sync-spec.mjs.\n" +
|
||||||
|
`// Source: ${specUrl}\n` +
|
||||||
|
`// Generated: ${new Date().toISOString()}\n\n`;
|
||||||
|
await writeFile(outFile, banner + dts, "utf8");
|
||||||
|
console.log(`✓ wrote ${outFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
209
src/client.ts
Normal file
209
src/client.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
// Core HTTP client. Hand-rolled around fetch with hooks for auth, tenant
|
||||||
|
// context, idempotency, and rate-limit-aware retry.
|
||||||
|
//
|
||||||
|
// Two surfaces:
|
||||||
|
// - client.GET/POST/PUT/PATCH/DELETE — generic, accepts any string path.
|
||||||
|
// Returns parsed JSON or throws ArcadiaError. Use this for paths that
|
||||||
|
// aren't in the OpenAPI spec, or when the spec is incomplete (arcadia
|
||||||
|
// has some "ok"-placeholder operations that don't generate operation
|
||||||
|
// types — see scripts/sync-spec.mjs).
|
||||||
|
// - client.typed.GET/POST/... — openapi-fetch-backed, fully typed against
|
||||||
|
// the generated `paths`. Same auth/retry plumbing. Returns the parsed
|
||||||
|
// `data` or throws ArcadiaError.
|
||||||
|
//
|
||||||
|
// The client is stateless about how the JWT is obtained — callers pass
|
||||||
|
// `getToken` so refresh and storage stay app-owned.
|
||||||
|
|
||||||
|
import createOpenapiClient, { type Client as OpenapiClient } from "openapi-fetch";
|
||||||
|
|
||||||
|
import { normalizeErrorResponse, ArcadiaError } from "./errors";
|
||||||
|
import type { ArcadiaTenantId } from "./types";
|
||||||
|
import type { paths } from "./generated/openapi";
|
||||||
|
|
||||||
|
export interface ArcadiaClientOptions {
|
||||||
|
baseUrl: string;
|
||||||
|
getToken?: () => string | null | Promise<string | null>;
|
||||||
|
apiKey?: string;
|
||||||
|
apiKeyMode?: "fallback" | "always";
|
||||||
|
tenantId?: ArcadiaTenantId;
|
||||||
|
onUnauthorized?: (err: ArcadiaError) => void;
|
||||||
|
/** Number of retries on 429 / 503. Default 2. Honors Retry-After. */
|
||||||
|
maxRetries?: number;
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestOptions {
|
||||||
|
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
/** Query params (URL search params). null/undefined are dropped. */
|
||||||
|
params?: Record<string, string | number | boolean | null | undefined>;
|
||||||
|
/** Request body. Plain object → JSON. FormData / Blob → sent as-is. */
|
||||||
|
body?: unknown;
|
||||||
|
/** Idempotency key for safe retries on POST/PUT/PATCH/DELETE. */
|
||||||
|
idempotencyKey?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** openapi-fetch's typed client, wrapped so methods return data and throw
|
||||||
|
* ArcadiaError on failure (matching the rest of the API). */
|
||||||
|
export type TypedArcadiaClient = OpenapiClient<paths>;
|
||||||
|
|
||||||
|
export interface ArcadiaClient {
|
||||||
|
request<T = unknown>(path: string, opts?: RequestOptions): Promise<T>;
|
||||||
|
GET<T = unknown>(path: string, opts?: Omit<RequestOptions, "method" | "body">): Promise<T>;
|
||||||
|
POST<T = unknown>(path: string, opts?: Omit<RequestOptions, "method">): Promise<T>;
|
||||||
|
PUT<T = unknown>(path: string, opts?: Omit<RequestOptions, "method">): Promise<T>;
|
||||||
|
PATCH<T = unknown>(path: string, opts?: Omit<RequestOptions, "method">): Promise<T>;
|
||||||
|
DELETE<T = unknown>(path: string, opts?: Omit<RequestOptions, "method">): Promise<T>;
|
||||||
|
/** Fully typed client for paths in the OpenAPI spec. Same auth/retry
|
||||||
|
* plumbing; throws ArcadiaError on non-2xx. */
|
||||||
|
typed: TypedArcadiaClient;
|
||||||
|
getTenantId(): ArcadiaTenantId | undefined;
|
||||||
|
setTenantId(id: ArcadiaTenantId | undefined): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createArcadiaClient(opts: ArcadiaClientOptions): ArcadiaClient {
|
||||||
|
const baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
||||||
|
const realFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
||||||
|
const apiKeyMode = opts.apiKeyMode ?? "fallback";
|
||||||
|
const maxRetries = opts.maxRetries ?? 2;
|
||||||
|
|
||||||
|
let tenantId = opts.tenantId;
|
||||||
|
|
||||||
|
// Shared fetch: header injection + retry. Used both by `request()` and as
|
||||||
|
// the underlying transport for openapi-fetch (`typed`).
|
||||||
|
const transportFetch: typeof fetch = async (input, init) => {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
headers.set("Accept", headers.get("Accept") ?? "application/json");
|
||||||
|
|
||||||
|
const token = opts.getToken ? await opts.getToken() : null;
|
||||||
|
if (token && !headers.has("Authorization")) headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
if (opts.apiKey && (apiKeyMode === "always" || !token) && !headers.has("X-API-Key")) {
|
||||||
|
headers.set("X-API-Key", opts.apiKey);
|
||||||
|
}
|
||||||
|
if (tenantId && !headers.has("X-Tenant-ID")) headers.set("X-Tenant-ID", tenantId);
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
const res = await realFetch(input, { ...init, headers });
|
||||||
|
if ((res.status === 429 || res.status === 503) && attempt < maxRetries) {
|
||||||
|
attempt += 1;
|
||||||
|
const wait = parseRetryAfter(res.headers.get("Retry-After")) ?? backoff(attempt);
|
||||||
|
await sleep(wait);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the openapi-fetch-backed typed client. We then wrap each verb so
|
||||||
|
// it throws ArcadiaError on non-2xx instead of returning `{ data, error }`.
|
||||||
|
const oa = createOpenapiClient<paths>({ baseUrl, fetch: transportFetch });
|
||||||
|
const typed = wrapOpenapi(oa, opts.onUnauthorized);
|
||||||
|
|
||||||
|
async function request<T>(path: string, ro: RequestOptions = {}): Promise<T> {
|
||||||
|
const method = ro.method ?? "GET";
|
||||||
|
const url = buildUrl(baseUrl, path, ro.params);
|
||||||
|
const headers: Record<string, string> = { ...(ro.headers ?? {}) };
|
||||||
|
if (ro.idempotencyKey && method !== "GET") headers["X-Idempotency-Key"] = ro.idempotencyKey;
|
||||||
|
|
||||||
|
let body: BodyInit | undefined;
|
||||||
|
if (ro.body !== undefined && ro.body !== null) {
|
||||||
|
if (ro.body instanceof FormData || ro.body instanceof Blob || typeof ro.body === "string") {
|
||||||
|
body = ro.body as BodyInit;
|
||||||
|
} else {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
body = JSON.stringify(ro.body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await transportFetch(url, { method, headers, body, signal: ro.signal });
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) return undefined as T;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
return text as unknown as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = await normalizeErrorResponse(res);
|
||||||
|
if (err.isAuth) opts.onUnauthorized?.(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
request,
|
||||||
|
GET: (p, o) => request(p, { ...o, method: "GET" }),
|
||||||
|
POST: (p, o) => request(p, { ...o, method: "POST" }),
|
||||||
|
PUT: (p, o) => request(p, { ...o, method: "PUT" }),
|
||||||
|
PATCH: (p, o) => request(p, { ...o, method: "PATCH" }),
|
||||||
|
DELETE: (p, o) => request(p, { ...o, method: "DELETE" }),
|
||||||
|
typed,
|
||||||
|
getTenantId: () => tenantId,
|
||||||
|
setTenantId: (id) => {
|
||||||
|
tenantId = id;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap openapi-fetch so each verb throws ArcadiaError on non-2xx instead
|
||||||
|
* of returning `{ data, error }`. Preserves full type inference because
|
||||||
|
* we forward the original method's signature. */
|
||||||
|
function wrapOpenapi(
|
||||||
|
oa: OpenapiClient<paths>,
|
||||||
|
onUnauthorized?: (err: ArcadiaError) => void,
|
||||||
|
): TypedArcadiaClient {
|
||||||
|
const verbs = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "TRACE"] as const;
|
||||||
|
const out: Record<string, unknown> = { ...oa };
|
||||||
|
for (const verb of verbs) {
|
||||||
|
const orig = (oa as unknown as Record<string, (...args: unknown[]) => unknown>)[verb];
|
||||||
|
if (typeof orig !== "function") continue;
|
||||||
|
out[verb] = async (...args: unknown[]) => {
|
||||||
|
const result = (await orig.apply(oa, args)) as { data?: unknown; error?: unknown; response: Response };
|
||||||
|
if (result.error !== undefined && !result.response.ok) {
|
||||||
|
const err = await normalizeErrorResponse(result.response);
|
||||||
|
if (err.isAuth) onUnauthorized?.(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return out as unknown as TypedArcadiaClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(
|
||||||
|
baseUrl: string,
|
||||||
|
path: string,
|
||||||
|
params?: Record<string, string | number | boolean | null | undefined>,
|
||||||
|
): string {
|
||||||
|
const url = path.startsWith("http") ? path : `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
|
if (!params) return url;
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v === null || v === undefined) continue;
|
||||||
|
qs.append(k, String(v));
|
||||||
|
}
|
||||||
|
const s = qs.toString();
|
||||||
|
return s ? `${url}${url.includes("?") ? "&" : "?"}${s}` : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRetryAfter(h: string | null): number | null {
|
||||||
|
if (!h) return null;
|
||||||
|
const seconds = Number(h);
|
||||||
|
if (Number.isFinite(seconds)) return seconds * 1000;
|
||||||
|
const date = Date.parse(h);
|
||||||
|
if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function backoff(attempt: number): number {
|
||||||
|
return Math.min(4000, 250 * Math.pow(4, attempt - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
77
src/errors.ts
Normal file
77
src/errors.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Error normalization for the arcadia API.
|
||||||
|
//
|
||||||
|
// Arcadia returns errors as JSON in the shape:
|
||||||
|
// { error: string, message: string, details?: object, request_id?: string }
|
||||||
|
// with appropriate HTTP status. ArcadiaError preserves all of that plus the
|
||||||
|
// status code so callers can branch on either code (`error === "rate_limited"`)
|
||||||
|
// or status (`status === 429`).
|
||||||
|
|
||||||
|
export interface ArcadiaErrorBody {
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
request_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ArcadiaError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code: string;
|
||||||
|
readonly details?: Record<string, unknown>;
|
||||||
|
readonly requestId?: string;
|
||||||
|
readonly raw?: unknown;
|
||||||
|
|
||||||
|
constructor(opts: {
|
||||||
|
status: number;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
requestId?: string;
|
||||||
|
raw?: unknown;
|
||||||
|
}) {
|
||||||
|
super(opts.message);
|
||||||
|
this.name = "ArcadiaError";
|
||||||
|
this.status = opts.status;
|
||||||
|
this.code = opts.code;
|
||||||
|
this.details = opts.details;
|
||||||
|
this.requestId = opts.requestId;
|
||||||
|
this.raw = opts.raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isAuth(): boolean {
|
||||||
|
return this.status === 401;
|
||||||
|
}
|
||||||
|
get isForbidden(): boolean {
|
||||||
|
return this.status === 403;
|
||||||
|
}
|
||||||
|
get isNotFound(): boolean {
|
||||||
|
return this.status === 404;
|
||||||
|
}
|
||||||
|
get isValidation(): boolean {
|
||||||
|
return this.status === 422;
|
||||||
|
}
|
||||||
|
get isRateLimited(): boolean {
|
||||||
|
return this.status === 429;
|
||||||
|
}
|
||||||
|
get isServer(): boolean {
|
||||||
|
return this.status >= 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeErrorResponse(res: Response): Promise<ArcadiaError> {
|
||||||
|
let body: Partial<ArcadiaErrorBody> = {};
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = await res.clone().json();
|
||||||
|
if (raw && typeof raw === "object") body = raw as ArcadiaErrorBody;
|
||||||
|
} catch {
|
||||||
|
// Non-JSON error (e.g. HTML 502 from a proxy). Fall through.
|
||||||
|
}
|
||||||
|
return new ArcadiaError({
|
||||||
|
status: res.status,
|
||||||
|
code: body.error ?? `http_${res.status}`,
|
||||||
|
message: body.message ?? res.statusText ?? `Request failed with ${res.status}`,
|
||||||
|
details: body.details,
|
||||||
|
requestId: body.request_id,
|
||||||
|
raw,
|
||||||
|
});
|
||||||
|
}
|
||||||
2006
src/generated/openapi.d.ts
vendored
Normal file
2006
src/generated/openapi.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
30
src/index.tsx
Normal file
30
src/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// PURPOSE: Typed HTTP client + React bindings for the arcadia Phoenix API.
|
||||||
|
// Wraps the OpenAPI-spec'd surface at /api/v1 with auth (Bearer JWT
|
||||||
|
// and/or X-API-Key), tenant context (X-Tenant-ID), idempotency
|
||||||
|
// keys, error normalization, and rate-limit-aware retry. Realtime
|
||||||
|
// (Phoenix Channels at /socket/tenant) lives alongside in the same
|
||||||
|
// provider so apps can subscribe to notification / digital_object /
|
||||||
|
// announcement / status_update / event topics with the same auth.
|
||||||
|
//
|
||||||
|
// The client is stateless about how the JWT is obtained — callers
|
||||||
|
// pass `getToken` so refresh and storage stay app-owned. Generated
|
||||||
|
// types under ./generated/openapi.d.ts are produced by the
|
||||||
|
// sync-spec script (see ../scripts/sync-spec.mjs).
|
||||||
|
// ===========================================================================
|
||||||
|
// EXPORTS
|
||||||
|
// Client: createArcadiaClient, type ArcadiaClient, type TypedArcadiaClient,
|
||||||
|
// type ArcadiaClientOptions
|
||||||
|
// Errors: ArcadiaError, type ArcadiaErrorBody
|
||||||
|
// Provider: ArcadiaProvider, useArcadia, useArcadiaClient,
|
||||||
|
// useArcadiaSubscription
|
||||||
|
// Realtime: createArcadiaRealtime, socketUrlFromBaseUrl,
|
||||||
|
// type ArcadiaRealtime, type TenantEventMap, type RealtimeStatus
|
||||||
|
// Types: re-exported subset from ./types and ./generated/openapi
|
||||||
|
// ===========================================================================
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
export * from "./client";
|
||||||
|
export * from "./errors";
|
||||||
|
export * from "./provider";
|
||||||
|
export * from "./realtime";
|
||||||
|
export * from "./types";
|
||||||
143
src/provider.tsx
Normal file
143
src/provider.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { createArcadiaClient, type ArcadiaClient, type ArcadiaClientOptions } from "./client";
|
||||||
|
import {
|
||||||
|
createArcadiaRealtime,
|
||||||
|
socketUrlFromBaseUrl,
|
||||||
|
type ArcadiaRealtime,
|
||||||
|
type RealtimeScope,
|
||||||
|
type RealtimeStatus,
|
||||||
|
type TenantEventMap,
|
||||||
|
} from "./realtime";
|
||||||
|
|
||||||
|
interface ArcadiaContextValue {
|
||||||
|
client: ArcadiaClient;
|
||||||
|
/** Current tenant id (state, so consumers re-render on switch). */
|
||||||
|
tenantId: string | undefined;
|
||||||
|
setTenantId: (id: string | undefined) => void;
|
||||||
|
/** Realtime instance (if `enableRealtime` was set on the provider). */
|
||||||
|
realtime: ArcadiaRealtime | null;
|
||||||
|
/** Realtime status, kept in state for ergonomic UI. */
|
||||||
|
realtimeStatus: RealtimeStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<ArcadiaContextValue | null>(null);
|
||||||
|
|
||||||
|
export interface ArcadiaProviderProps extends Omit<ArcadiaClientOptions, "tenantId"> {
|
||||||
|
/** Initial tenant id. Use `setTenantId` from the hook to switch at runtime. */
|
||||||
|
initialTenantId?: string;
|
||||||
|
/** User id for user-scoped realtime subscriptions. Optional. */
|
||||||
|
userId?: string;
|
||||||
|
/** Enable Phoenix Channels realtime. Default false. When true and
|
||||||
|
* tenantId is set, the provider auto-connects on mount. */
|
||||||
|
enableRealtime?: boolean;
|
||||||
|
/** Override socket URL. Defaults to socketUrlFromBaseUrl(baseUrl). */
|
||||||
|
socketUrl?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArcadiaProvider({
|
||||||
|
initialTenantId,
|
||||||
|
userId,
|
||||||
|
enableRealtime = false,
|
||||||
|
socketUrl,
|
||||||
|
children,
|
||||||
|
...clientOpts
|
||||||
|
}: ArcadiaProviderProps) {
|
||||||
|
const [tenantId, setTenantIdState] = useState<string | undefined>(initialTenantId);
|
||||||
|
const [realtimeStatus, setRealtimeStatus] = useState<RealtimeStatus>("idle");
|
||||||
|
|
||||||
|
// Keep callable refs so we don't rebuild the client on every render.
|
||||||
|
const optsRef = useRef(clientOpts);
|
||||||
|
optsRef.current = clientOpts;
|
||||||
|
|
||||||
|
const client = useMemo(
|
||||||
|
() =>
|
||||||
|
createArcadiaClient({
|
||||||
|
...clientOpts,
|
||||||
|
tenantId: initialTenantId,
|
||||||
|
getToken: () => optsRef.current.getToken?.() ?? null,
|
||||||
|
onUnauthorized: (err) => optsRef.current.onUnauthorized?.(err),
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[clientOpts.baseUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep client tenant in lockstep with React state.
|
||||||
|
useEffect(() => {
|
||||||
|
client.setTenantId(tenantId);
|
||||||
|
}, [client, tenantId]);
|
||||||
|
|
||||||
|
// Realtime lifecycle. Recreated when tenantId, userId, or socket URL change.
|
||||||
|
const realtime = useMemo<ArcadiaRealtime | null>(() => {
|
||||||
|
if (!enableRealtime || !tenantId) return null;
|
||||||
|
if (typeof window === "undefined") return null; // SSR-safe
|
||||||
|
return createArcadiaRealtime({
|
||||||
|
socketUrl: socketUrl ?? socketUrlFromBaseUrl(clientOpts.baseUrl),
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
getToken: () => optsRef.current.getToken?.() ?? null,
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [enableRealtime, tenantId, userId, socketUrl, clientOpts.baseUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!realtime) return;
|
||||||
|
const off = realtime.onStatusChange(setRealtimeStatus);
|
||||||
|
realtime.connect();
|
||||||
|
return () => {
|
||||||
|
off();
|
||||||
|
realtime.disconnect();
|
||||||
|
};
|
||||||
|
}, [realtime]);
|
||||||
|
|
||||||
|
const value = useMemo<ArcadiaContextValue>(
|
||||||
|
() => ({ client, tenantId, setTenantId: setTenantIdState, realtime, realtimeStatus }),
|
||||||
|
[client, tenantId, realtime, realtimeStatus],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArcadia(): ArcadiaContextValue {
|
||||||
|
const v = useContext(Ctx);
|
||||||
|
if (!v) throw new Error("useArcadia must be used inside <ArcadiaProvider>");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: most consumers only need the client. */
|
||||||
|
export function useArcadiaClient(): ArcadiaClient {
|
||||||
|
return useArcadia().client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to a Phoenix Channels event for as long as the component is
|
||||||
|
* mounted. No-op if the provider was created without `enableRealtime`. The
|
||||||
|
* callback is captured fresh on every render via a ref so consumers don't
|
||||||
|
* need to memoize it. */
|
||||||
|
export function useArcadiaSubscription<E extends keyof TenantEventMap>(
|
||||||
|
event: E,
|
||||||
|
callback: (payload: TenantEventMap[E]) => void,
|
||||||
|
opts?: { scope?: RealtimeScope; enabled?: boolean },
|
||||||
|
): void {
|
||||||
|
const { realtime, realtimeStatus } = useArcadia();
|
||||||
|
const cbRef = useRef(callback);
|
||||||
|
cbRef.current = callback;
|
||||||
|
|
||||||
|
const enabled = opts?.enabled ?? true;
|
||||||
|
const scope = opts?.scope ?? "tenant";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!realtime || !enabled || realtimeStatus !== "open") return;
|
||||||
|
const unsub = realtime.on(event, ((payload) => cbRef.current(payload)) as (p: TenantEventMap[E]) => void, scope);
|
||||||
|
return unsub;
|
||||||
|
}, [realtime, realtimeStatus, event, enabled, scope]);
|
||||||
|
}
|
||||||
163
src/realtime.ts
Normal file
163
src/realtime.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
// Realtime over Phoenix Channels at /socket/tenant.
|
||||||
|
//
|
||||||
|
// Arcadia exposes a tenant-scoped socket; clients authenticate by passing
|
||||||
|
// the JWT in connect params. After joining `tenant:<tenant_id>` the server
|
||||||
|
// pushes events: notification, digital_object, announcement, status_update,
|
||||||
|
// event. A user-scoped sub-topic `tenant:<id>:user:<user_id>` carries the
|
||||||
|
// same shape filtered to the current user.
|
||||||
|
//
|
||||||
|
// SSR-safe: no WebSocket reference is created until connect() is called.
|
||||||
|
|
||||||
|
import { Socket, Channel } from "phoenix";
|
||||||
|
import type { ArcadiaTenantId } from "./types";
|
||||||
|
|
||||||
|
/** Known event payloads. Open-ended — arcadia may add more, and apps can
|
||||||
|
* augment via module declaration merging or just use the indexed access. */
|
||||||
|
export interface TenantEventMap {
|
||||||
|
notification: { id: string; level?: "info" | "warning" | "error"; title: string; body?: string; [k: string]: unknown };
|
||||||
|
digital_object: { id: string; action: "created" | "updated" | "deleted" | "restored"; [k: string]: unknown };
|
||||||
|
announcement: { id: string; title: string; body?: string; [k: string]: unknown };
|
||||||
|
status_update: { id: string; component?: string; status?: string; [k: string]: unknown };
|
||||||
|
event: { type: string; payload?: unknown; [k: string]: unknown };
|
||||||
|
[key: string]: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RealtimeStatus = "idle" | "connecting" | "open" | "closed" | "error";
|
||||||
|
export type RealtimeScope = "tenant" | "user";
|
||||||
|
|
||||||
|
export interface ArcadiaRealtimeOptions {
|
||||||
|
/** Full URL to the Phoenix socket endpoint. Example:
|
||||||
|
* "ws://localhost:4000/socket/tenant" or "wss://api.example.com/socket/tenant".
|
||||||
|
* If you only have an HTTP base URL, see `socketUrlFromBaseUrl`. */
|
||||||
|
socketUrl: string;
|
||||||
|
/** Tenant id whose topic we'll join (e.g. UUID or slug). */
|
||||||
|
tenantId: ArcadiaTenantId;
|
||||||
|
/** Optional user id; required if you want user-scoped subscriptions. */
|
||||||
|
userId?: string;
|
||||||
|
/** Returns the JWT to send as Phoenix connect param `token`. Called
|
||||||
|
* once on connect; reconnect re-evaluates so token rotation works. */
|
||||||
|
getToken: () => string | null | Promise<string | null>;
|
||||||
|
/** Heartbeat ms. Phoenix default is 30000. */
|
||||||
|
heartbeatIntervalMs?: number;
|
||||||
|
/** Called for any unexpected error (auth failure, channel join refused). */
|
||||||
|
onError?: (err: Error) => void;
|
||||||
|
/** Custom Socket factory (for tests / mocks). */
|
||||||
|
socketFactory?: (endpoint: string, opts: ConstructorParameters<typeof Socket>[1]) => Socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArcadiaRealtime {
|
||||||
|
/** Open the socket and join tenant + (optionally) user channels. Idempotent. */
|
||||||
|
connect(): Promise<void>;
|
||||||
|
/** Tear everything down. */
|
||||||
|
disconnect(): void;
|
||||||
|
/** Subscribe to an event. Returns an unsubscribe function.
|
||||||
|
* scope="tenant" (default) targets `tenant:<id>`.
|
||||||
|
* scope="user" targets `tenant:<id>:user:<userId>` (requires userId). */
|
||||||
|
on<E extends keyof TenantEventMap>(
|
||||||
|
event: E,
|
||||||
|
callback: (payload: TenantEventMap[E]) => void,
|
||||||
|
scope?: RealtimeScope,
|
||||||
|
): () => void;
|
||||||
|
/** Current status. */
|
||||||
|
status: RealtimeStatus;
|
||||||
|
/** Subscribe to status changes. Returns an unsubscribe fn. */
|
||||||
|
onStatusChange(cb: (status: RealtimeStatus) => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert an http(s) base URL into the matching ws(s) socket URL. */
|
||||||
|
export function socketUrlFromBaseUrl(baseUrl: string, path = "/socket/tenant"): string {
|
||||||
|
const u = new URL(baseUrl);
|
||||||
|
const proto = u.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${proto}//${u.host}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createArcadiaRealtime(opts: ArcadiaRealtimeOptions): ArcadiaRealtime {
|
||||||
|
let socket: Socket | null = null;
|
||||||
|
let tenantChannel: Channel | null = null;
|
||||||
|
let userChannel: Channel | null = null;
|
||||||
|
let _status: RealtimeStatus = "idle";
|
||||||
|
const statusListeners = new Set<(s: RealtimeStatus) => void>();
|
||||||
|
|
||||||
|
function setStatus(next: RealtimeStatus) {
|
||||||
|
if (_status === next) return;
|
||||||
|
_status = next;
|
||||||
|
for (const cb of statusListeners) cb(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(): Promise<void> {
|
||||||
|
if (socket) return;
|
||||||
|
setStatus("connecting");
|
||||||
|
|
||||||
|
const token = await opts.getToken();
|
||||||
|
const factory =
|
||||||
|
opts.socketFactory ?? ((endpoint, o) => new Socket(endpoint, o));
|
||||||
|
|
||||||
|
socket = factory(opts.socketUrl, {
|
||||||
|
params: () => ({ token }),
|
||||||
|
heartbeatIntervalMs: opts.heartbeatIntervalMs ?? 30000,
|
||||||
|
});
|
||||||
|
socket.onOpen(() => setStatus("open"));
|
||||||
|
socket.onClose(() => setStatus("closed"));
|
||||||
|
socket.onError((err) => {
|
||||||
|
setStatus("error");
|
||||||
|
opts.onError?.(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
});
|
||||||
|
socket.connect();
|
||||||
|
|
||||||
|
tenantChannel = socket.channel(`tenant:${opts.tenantId}`, {});
|
||||||
|
tenantChannel
|
||||||
|
.join()
|
||||||
|
.receive("error", (resp) => opts.onError?.(new Error(`tenant channel join failed: ${JSON.stringify(resp)}`)));
|
||||||
|
|
||||||
|
if (opts.userId) {
|
||||||
|
userChannel = socket.channel(`tenant:${opts.tenantId}:user:${opts.userId}`, {});
|
||||||
|
userChannel
|
||||||
|
.join()
|
||||||
|
.receive("error", (resp) =>
|
||||||
|
opts.onError?.(new Error(`user channel join failed: ${JSON.stringify(resp)}`)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
tenantChannel?.leave();
|
||||||
|
userChannel?.leave();
|
||||||
|
socket?.disconnect();
|
||||||
|
tenantChannel = null;
|
||||||
|
userChannel = null;
|
||||||
|
socket = null;
|
||||||
|
setStatus("idle");
|
||||||
|
}
|
||||||
|
|
||||||
|
function on<E extends keyof TenantEventMap>(
|
||||||
|
event: E,
|
||||||
|
callback: (payload: TenantEventMap[E]) => void,
|
||||||
|
scope: RealtimeScope = "tenant",
|
||||||
|
): () => void {
|
||||||
|
const channel = scope === "user" ? userChannel : tenantChannel;
|
||||||
|
if (!channel) {
|
||||||
|
// Connection not established yet — return a no-op unsubscribe; caller
|
||||||
|
// should retry after connect() resolves. The React hook handles this.
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const ref = channel.on(event as string, callback as (payload: unknown) => void);
|
||||||
|
return () => {
|
||||||
|
channel.off(event as string, ref);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStatusChange(cb: (status: RealtimeStatus) => void): () => void {
|
||||||
|
statusListeners.add(cb);
|
||||||
|
return () => statusListeners.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
on,
|
||||||
|
get status() {
|
||||||
|
return _status;
|
||||||
|
},
|
||||||
|
onStatusChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
47
src/types.ts
Normal file
47
src/types.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Hand-written shared types.
|
||||||
|
//
|
||||||
|
// Per-endpoint request/response types come from the generated OpenAPI types
|
||||||
|
// (see ./generated/openapi.d.ts after running `sync-spec`). The shapes here
|
||||||
|
// are the ones that exist *around* every request — wrappers, pagination,
|
||||||
|
// auth payloads — and don't belong to any one endpoint.
|
||||||
|
|
||||||
|
/** Successful response envelope used by most v1 endpoints. */
|
||||||
|
export interface ArcadiaEnvelope<T> {
|
||||||
|
data: T;
|
||||||
|
meta?: ArcadiaMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional metadata block on list responses (pagination, totals, etc). */
|
||||||
|
export interface ArcadiaMeta {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
total?: number;
|
||||||
|
total_pages?: number;
|
||||||
|
request_id?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auth-token pair returned by /auth/login, /auth/refresh, OAuth callbacks.
|
||||||
|
* `expires_in` is seconds-from-now (Phoenix Guardian default); compute the
|
||||||
|
* absolute expiry on the client if needed. */
|
||||||
|
export interface ArcadiaAuthTokens {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
token_type?: "Bearer";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal user shape used by useArcadia / auth-ui until generated types
|
||||||
|
* cover the whole User schema. Apps should prefer generated types when
|
||||||
|
* available. */
|
||||||
|
export interface ArcadiaUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
tenant_id?: string;
|
||||||
|
roles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tenant identifier — either a UUID string, or a slug if the deployment
|
||||||
|
* uses friendly tenant slugs. The client doesn't enforce a format. */
|
||||||
|
export type ArcadiaTenantId = string;
|
||||||
Reference in New Issue
Block a user