flipstream 0.4.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.
Files changed (102) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +659 -0
  3. package/bin/dev.js +9 -0
  4. package/bin/run.js +7 -0
  5. package/dist/commands/auth/clear-all.d.ts +10 -0
  6. package/dist/commands/auth/clear-all.js +50 -0
  7. package/dist/commands/auth/login.d.ts +11 -0
  8. package/dist/commands/auth/login.js +49 -0
  9. package/dist/commands/auth/logout.d.ts +10 -0
  10. package/dist/commands/auth/logout.js +51 -0
  11. package/dist/commands/auth/status.d.ts +8 -0
  12. package/dist/commands/auth/status.js +47 -0
  13. package/dist/commands/connections/list.d.ts +19 -0
  14. package/dist/commands/connections/list.js +43 -0
  15. package/dist/commands/query.d.ts +13 -0
  16. package/dist/commands/query.js +76 -0
  17. package/dist/commands/version.d.ts +15 -0
  18. package/dist/commands/version.js +17 -0
  19. package/dist/commands/workspaces/connections.d.ts +21 -0
  20. package/dist/commands/workspaces/connections.js +41 -0
  21. package/dist/commands/workspaces/get.d.ts +15 -0
  22. package/dist/commands/workspaces/get.js +65 -0
  23. package/dist/commands/workspaces/list.d.ts +20 -0
  24. package/dist/commands/workspaces/list.js +87 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/lib/api/admin-client.d.ts +19 -0
  28. package/dist/lib/api/admin-client.js +44 -0
  29. package/dist/lib/api/client.d.ts +13 -0
  30. package/dist/lib/api/client.js +21 -0
  31. package/dist/lib/api/connections.d.ts +14 -0
  32. package/dist/lib/api/connections.js +44 -0
  33. package/dist/lib/api/errors.d.ts +7 -0
  34. package/dist/lib/api/errors.js +68 -0
  35. package/dist/lib/api/http.d.ts +9 -0
  36. package/dist/lib/api/http.js +55 -0
  37. package/dist/lib/api/ids.d.ts +1 -0
  38. package/dist/lib/api/ids.js +8 -0
  39. package/dist/lib/api/list.d.ts +12 -0
  40. package/dist/lib/api/list.js +31 -0
  41. package/dist/lib/api/projections.d.ts +2 -0
  42. package/dist/lib/api/projections.js +34 -0
  43. package/dist/lib/api/retry.d.ts +13 -0
  44. package/dist/lib/api/retry.js +37 -0
  45. package/dist/lib/auth/claims.d.ts +3 -0
  46. package/dist/lib/auth/claims.js +20 -0
  47. package/dist/lib/auth/discovery.d.ts +13 -0
  48. package/dist/lib/auth/discovery.js +62 -0
  49. package/dist/lib/auth/flow.d.ts +22 -0
  50. package/dist/lib/auth/flow.js +135 -0
  51. package/dist/lib/auth/headless.d.ts +12 -0
  52. package/dist/lib/auth/headless.js +61 -0
  53. package/dist/lib/auth/loopback.d.ts +15 -0
  54. package/dist/lib/auth/loopback.js +198 -0
  55. package/dist/lib/auth/pkce.d.ts +6 -0
  56. package/dist/lib/auth/pkce.js +17 -0
  57. package/dist/lib/auth/provider.d.ts +15 -0
  58. package/dist/lib/auth/provider.js +23 -0
  59. package/dist/lib/auth/refresh.d.ts +6 -0
  60. package/dist/lib/auth/refresh.js +69 -0
  61. package/dist/lib/auth/register.d.ts +4 -0
  62. package/dist/lib/auth/register.js +43 -0
  63. package/dist/lib/auth/revoke.d.ts +8 -0
  64. package/dist/lib/auth/revoke.js +28 -0
  65. package/dist/lib/auth/session.d.ts +6 -0
  66. package/dist/lib/auth/session.js +13 -0
  67. package/dist/lib/command/admin.d.ts +4 -0
  68. package/dist/lib/command/admin.js +30 -0
  69. package/dist/lib/command/base.d.ts +28 -0
  70. package/dist/lib/command/base.js +75 -0
  71. package/dist/lib/command/crash.d.ts +2 -0
  72. package/dist/lib/command/crash.js +37 -0
  73. package/dist/lib/command/flags.d.ts +7 -0
  74. package/dist/lib/command/flags.js +10 -0
  75. package/dist/lib/config/constants.d.ts +5 -0
  76. package/dist/lib/config/constants.js +20 -0
  77. package/dist/lib/config/xdg.d.ts +21 -0
  78. package/dist/lib/config/xdg.js +97 -0
  79. package/dist/lib/errors.d.ts +38 -0
  80. package/dist/lib/errors.js +122 -0
  81. package/dist/lib/exit-codes.d.ts +10 -0
  82. package/dist/lib/exit-codes.js +13 -0
  83. package/dist/lib/help.d.ts +4 -0
  84. package/dist/lib/help.js +14 -0
  85. package/dist/lib/output/ndjson.d.ts +1 -0
  86. package/dist/lib/output/ndjson.js +16 -0
  87. package/dist/lib/output/redact.d.ts +1 -0
  88. package/dist/lib/output/redact.js +47 -0
  89. package/dist/lib/output/render.d.ts +2 -0
  90. package/dist/lib/output/render.js +8 -0
  91. package/dist/lib/output/table.d.ts +3 -0
  92. package/dist/lib/output/table.js +19 -0
  93. package/dist/lib/store/credentials.d.ts +12 -0
  94. package/dist/lib/store/credentials.js +40 -0
  95. package/dist/lib/store/index.d.ts +5 -0
  96. package/dist/lib/store/index.js +21 -0
  97. package/dist/lib/store/keyring.d.ts +16 -0
  98. package/dist/lib/store/keyring.js +45 -0
  99. package/dist/lib/store/memory-store.d.ts +10 -0
  100. package/dist/lib/store/memory-store.js +21 -0
  101. package/oclif.manifest.json +851 -0
  102. package/package.json +79 -0
@@ -0,0 +1,87 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { createAuthedAdminClient } from '../../lib/api/admin-client.js';
3
+ import { mapDataError } from '../../lib/api/errors.js';
4
+ import { drainPages, normalizeList } from '../../lib/api/list.js';
5
+ import { projectWorkspace } from '../../lib/api/projections.js';
6
+ import { refresh } from '../../lib/auth/refresh.js';
7
+ import { BaseCommand } from '../../lib/command/base.js';
8
+ import { paginationFlags } from '../../lib/command/flags.js';
9
+ import { AuthRequiredError } from '../../lib/errors.js';
10
+ import { renderTable } from '../../lib/output/table.js';
11
+ import { createStore } from '../../lib/store/index.js';
12
+ const COUNT_CONCURRENCY = 5;
13
+ export default class WorkspacesList extends BaseCommand {
14
+ static aliases = ['ws:list'];
15
+ static description = 'List the workspaces (the Flipstream business entity, "Client" in the admin API) in your organization. ' +
16
+ 'Account-scoped server-side (the CLI sends no org id). Targets the OAuth/admin host (whereas `query` targets ' +
17
+ 'the data host).';
18
+ static examples = [
19
+ '<%= config.bin %> workspaces list',
20
+ '<%= config.bin %> ws list --json',
21
+ '<%= config.bin %> ws list --all',
22
+ '<%= config.bin %> ws list --q acme --limit 10',
23
+ ];
24
+ static flags = {
25
+ ...paginationFlags,
26
+ 'with-connection-counts': Flags.boolean({
27
+ description: 'Add connection_count per workspace (opt-in: one extra request each, N+1).',
28
+ }),
29
+ };
30
+ static summary = "List your organization's workspaces.";
31
+ async run() {
32
+ // The admin host == the OAuth issuer (creds live here); --host overrides both.
33
+ const host = this.resolvedHost();
34
+ const store = createStore();
35
+ if (!store.load(host))
36
+ throw AuthRequiredError.notLoggedIn();
37
+ const client = createAuthedAdminClient({
38
+ accessTokenIfFresh: (authHost) => store.accessTokenIfFresh(authHost),
39
+ authHost: host,
40
+ host,
41
+ refresh: (authHost) => refresh(authHost, { store, timeoutMs: this.flags.timeout }),
42
+ timeoutMs: this.flags.timeout,
43
+ });
44
+ const fetchPage = async (page) => {
45
+ try {
46
+ const raw = await client.get('/clients', {
47
+ query: { limit: page.limit, offset: page.offset, q: this.flags.q, sort: this.flags.sort },
48
+ });
49
+ return normalizeList(raw);
50
+ }
51
+ catch (error) {
52
+ throw mapDataError(error);
53
+ }
54
+ };
55
+ const start = { limit: this.flags.limit, offset: this.flags.offset };
56
+ const envelope = this.flags.all ? await drainPages(fetchPage, start) : await fetchPage(start);
57
+ const projected = { count: envelope.count, records: envelope.records.map((record) => projectWorkspace(record)) };
58
+ if (this.flags['with-connection-counts'])
59
+ await this.addConnectionCounts(client, projected.records);
60
+ return this.respondList(projected, (records) => {
61
+ const columns = [
62
+ { key: 'name', name: 'Name' },
63
+ { key: 'id', name: 'ID' },
64
+ ];
65
+ if (this.flags['with-connection-counts'])
66
+ columns.push({ key: 'connection_count', name: 'Conns' });
67
+ renderTable(records, columns, 'No workspaces.');
68
+ });
69
+ }
70
+ // E7-R0: opt-in N+1 — fetch each workspace's connection count with bounded
71
+ // concurrency. Best-effort: a failed count is null, never failing the list.
72
+ async addConnectionCounts(client, records) {
73
+ for (let i = 0; i < records.length; i += COUNT_CONCURRENCY) {
74
+ const chunk = records.slice(i, i + COUNT_CONCURRENCY);
75
+ // eslint-disable-next-line no-await-in-loop -- bounded concurrency: one chunk at a time
76
+ await Promise.all(chunk.map(async (workspace) => {
77
+ try {
78
+ const conns = normalizeList(await client.get(`/clients/${String(workspace.id)}/connections`, { query: { limit: 1 } }));
79
+ workspace.connection_count = conns.count;
80
+ }
81
+ catch {
82
+ workspace.connection_count = null;
83
+ }
84
+ }));
85
+ }
86
+ }
87
+ }
@@ -0,0 +1 @@
1
+ export { run } from '@oclif/core';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { run } from '@oclif/core';
@@ -0,0 +1,19 @@
1
+ import { type RefreshDeps } from './retry.js';
2
+ export interface AdminGetOptions {
3
+ query?: Record<string, boolean | number | string | undefined>;
4
+ timeoutMs?: number;
5
+ }
6
+ export interface AdminClient {
7
+ get(path: string, options?: AdminGetOptions): Promise<unknown>;
8
+ }
9
+ export interface AdminClientOptions {
10
+ getToken: () => null | Promise<null | string> | string;
11
+ host?: string;
12
+ timeoutMs?: number;
13
+ }
14
+ export declare function createAdminClient(options: AdminClientOptions): AdminClient;
15
+ export interface AuthedAdminClientDeps extends RefreshDeps {
16
+ host?: string;
17
+ timeoutMs?: number;
18
+ }
19
+ export declare function createAuthedAdminClient(deps: AuthedAdminClientDeps): AdminClient;
@@ -0,0 +1,44 @@
1
+ import { ADMIN_HOST } from '../config/constants.js';
2
+ import { requestJson } from './http.js';
3
+ import { withFreshToken } from './retry.js';
4
+ const DEFAULT_TIMEOUT_MS = 30_000;
5
+ function rstrip(host) {
6
+ return host.replace(/\/+$/, '');
7
+ }
8
+ // Transport-only client to the ADMIN origin (today the same origin as the AUTH
9
+ // issuer — the stored bearer is accepted). GET with a path + query params; built
10
+ // on the shared requestJson core. No entity knowledge.
11
+ export function createAdminClient(options) {
12
+ const base = rstrip(options.host ?? ADMIN_HOST);
13
+ const defaultTimeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
14
+ return {
15
+ async get(path, getOptions = {}) {
16
+ const token = await options.getToken();
17
+ return requestJson({
18
+ method: 'GET',
19
+ query: getOptions.query,
20
+ timeoutMs: getOptions.timeoutMs ?? defaultTimeout,
21
+ token,
22
+ url: `${base}${path}`,
23
+ });
24
+ },
25
+ };
26
+ }
27
+ // The authed admin client: the SAME withFreshToken 401->refresh->retry-once
28
+ // wrapper (E7-0a) around an admin GET. Refresh targets the AUTH issuer (== admin
29
+ // host here). This is what commands use.
30
+ export function createAuthedAdminClient(deps) {
31
+ const base = rstrip(deps.host ?? ADMIN_HOST);
32
+ const defaultTimeout = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
33
+ return {
34
+ get(path, getOptions = {}) {
35
+ return withFreshToken(deps, (token) => requestJson({
36
+ method: 'GET',
37
+ query: getOptions.query,
38
+ timeoutMs: getOptions.timeoutMs ?? defaultTimeout,
39
+ token,
40
+ url: `${base}${path}`,
41
+ }));
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,13 @@
1
+ export interface DataClientOptions {
2
+ getToken: () => null | Promise<null | string> | string;
3
+ host?: string;
4
+ queryPath?: string;
5
+ timeoutMs?: number;
6
+ }
7
+ export interface PostQueryOptions {
8
+ timeoutMs?: number;
9
+ }
10
+ export interface DataClient {
11
+ postQuery(body: unknown, options?: PostQueryOptions): Promise<unknown>;
12
+ }
13
+ export declare function createDataClient(options: DataClientOptions): DataClient;
@@ -0,0 +1,21 @@
1
+ import { DATA_HOST } from '../config/constants.js';
2
+ import { requestJson } from './http.js';
3
+ const DEFAULT_QUERY_PATH = '/query';
4
+ const DEFAULT_TIMEOUT_MS = 30_000;
5
+ function rstrip(host) {
6
+ return host.replace(/\/+$/, '');
7
+ }
8
+ // Transport-only client to the DATA origin (distinct from the AUTH issuer). It
9
+ // attaches the bearer (in-process only), sends the OPAQUE body verbatim, and
10
+ // returns the parsed JSON on 2xx. It interprets NO query semantics. Built on the
11
+ // shared requestJson core; refresh/retry (E4-2) and error mapping (E4-3) wrap it.
12
+ export function createDataClient(options) {
13
+ const url = `${rstrip(options.host ?? DATA_HOST)}${options.queryPath ?? DEFAULT_QUERY_PATH}`;
14
+ const defaultTimeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
15
+ return {
16
+ async postQuery(body, callOptions = {}) {
17
+ const token = await options.getToken();
18
+ return requestJson({ body, method: 'POST', timeoutMs: callOptions.timeoutMs ?? defaultTimeout, token, url });
19
+ },
20
+ };
21
+ }
@@ -0,0 +1,14 @@
1
+ import { type AdminClient } from './admin-client.js';
2
+ export interface ConnectionsQuery {
3
+ all?: boolean;
4
+ limit: number;
5
+ offset: number;
6
+ q?: string;
7
+ sort?: string;
8
+ workspace?: string;
9
+ }
10
+ export declare function fetchConnections(client: AdminClient, query: ConnectionsQuery): Promise<{
11
+ count: number;
12
+ records: Array<Record<string, unknown>>;
13
+ }>;
14
+ export declare function resolveWorkspaceNames(client: AdminClient, records: Array<Record<string, unknown>>): Promise<void>;
@@ -0,0 +1,44 @@
1
+ import { mapDataError } from './errors.js';
2
+ import { drainPages, normalizeList } from './list.js';
3
+ import { projectConnection } from './projections.js';
4
+ // Fetch + project connections (org-wide or per-workspace), draining pages when
5
+ // requested. Errors are mapped to the E3 model.
6
+ export async function fetchConnections(client, query) {
7
+ const path = query.workspace ? `/clients/${query.workspace}/connections` : '/connections';
8
+ const fetchPage = async (page) => {
9
+ try {
10
+ return normalizeList(await client.get(path, { query: { limit: page.limit, offset: page.offset, q: query.q, sort: query.sort } }));
11
+ }
12
+ catch (error) {
13
+ throw mapDataError(error);
14
+ }
15
+ };
16
+ const start = { limit: query.limit, offset: query.offset };
17
+ const envelope = query.all ? await drainPages(fetchPage, start) : await fetchPage(start);
18
+ return { count: envelope.count, records: envelope.records.map((record) => projectConnection(record)) };
19
+ }
20
+ // Fill `client_name` for connections that lack it (the API didn't nest the
21
+ // client): one drained workspace lookup, keyed by id. Skipped when all already
22
+ // have it; best-effort (leaves names unresolved rather than failing the list).
23
+ export async function resolveWorkspaceNames(client, records) {
24
+ const missing = records.some((record) => record.client_name === undefined && record.client_id !== undefined);
25
+ if (!missing)
26
+ return;
27
+ const names = new Map();
28
+ try {
29
+ const workspaces = await drainPages(async (page) => normalizeList(await client.get('/clients', { query: { limit: page.limit, offset: page.offset } })), { limit: 100, offset: 0 });
30
+ for (const workspace of workspaces.records) {
31
+ const ws = workspace;
32
+ if (ws.id)
33
+ names.set(ws.id, ws.name ?? '');
34
+ }
35
+ }
36
+ catch {
37
+ return;
38
+ }
39
+ for (const record of records) {
40
+ if (record.client_name === undefined && typeof record.client_id === 'string') {
41
+ record.client_name = names.get(record.client_id) ?? null;
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,7 @@
1
+ import { CliError } from '../errors.js';
2
+ export declare function parseDataErrorMessage(status: number, bodyText: string): {
3
+ error?: string;
4
+ message: string;
5
+ };
6
+ export declare function notFoundError(): CliError;
7
+ export declare function mapDataError(error: unknown): CliError;
@@ -0,0 +1,68 @@
1
+ import { AuthFailedError, CliError, DataHttpError, NetworkError } from '../errors.js';
2
+ import { ExitCode } from '../exit-codes.js';
3
+ import { redact } from '../output/redact.js';
4
+ const DETAIL_MAX = 500;
5
+ // Parse an error body into a useful message. RFC 6749 {error, error_description}
6
+ // (the OAuth/auth path) becomes '<error>: <desc>'. The DATA host instead returns
7
+ // a {detail} body (e.g. FastAPI 422 validation) — surface it (truncated) as
8
+ // 'HTTP <status>: <detail>' so an agent can fix its query rather than seeing a
9
+ // bare status. A non-JSON / fieldless body degrades to 'HTTP <status>'. The
10
+ // message is redacted so a malicious/echoing body cannot smuggle a token in.
11
+ export function parseDataErrorMessage(status, bodyText) {
12
+ let providerError;
13
+ let raw = `HTTP ${status}`;
14
+ try {
15
+ const parsed = JSON.parse(bodyText);
16
+ if (parsed.error) {
17
+ providerError = parsed.error;
18
+ raw = parsed.error_description ? `${parsed.error}: ${parsed.error_description}` : parsed.error;
19
+ }
20
+ else if (parsed.detail !== undefined) {
21
+ const detail = typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail);
22
+ raw = `HTTP ${status}: ${detail.slice(0, DETAIL_MAX)}`;
23
+ }
24
+ }
25
+ catch {
26
+ // Not JSON — keep the 'HTTP <status>' fallback.
27
+ }
28
+ return { error: providerError, message: redact(raw) };
29
+ }
30
+ // The collapsed, existence-safe 404/foreign-id error (exit 1, stable 'not_found'
31
+ // code). Shared by the 404 mapper branch and the by-id commands, which also
32
+ // collapse a 403-on-a-foreign-id to this to avoid leaking existence.
33
+ export function notFoundError() {
34
+ return new CliError('Resource not found or not in your organization.', 'not_found', ExitCode.GENERIC);
35
+ }
36
+ // Map a DataClient/retry failure onto the E3 error model with a deterministic exit
37
+ // code (AuthRequired->4, AuthFailed->5, Network->7, Timeout->8). Network, Timeout
38
+ // and AuthRequired (session_expired) pass through unchanged; a DataHttpError is
39
+ // classified by provider error + status. The audience-mismatch case
40
+ // (invalid_target) is surfaced explicitly as a re-bind-required auth error, never
41
+ // a generic 500. Tokens never reach the message (redacted in parseDataErrorMessage).
42
+ export function mapDataError(error) {
43
+ if (error instanceof DataHttpError) {
44
+ const { error: providerError, message } = parseDataErrorMessage(error.status, error.bodyText);
45
+ if (providerError === 'invalid_target') {
46
+ return new AuthFailedError(`invalid_target: re-bind required — ${message}`, 'invalid_target');
47
+ }
48
+ if (error.status === 403) {
49
+ // Fixed message (NOT the upstream body) — stable agent guidance, no leak.
50
+ return new AuthFailedError('Your role cannot perform this action.', 'role_forbidden');
51
+ }
52
+ if (error.status === 401) {
53
+ return new AuthFailedError(message, 'data_auth_failed');
54
+ }
55
+ if (error.status === 404)
56
+ return notFoundError();
57
+ if (error.status >= 500) {
58
+ return new NetworkError(message, 'data_upstream_error');
59
+ }
60
+ return new CliError(message, providerError ?? 'data_request_failed', ExitCode.GENERIC);
61
+ }
62
+ // Network/Timeout/AuthRequired (and any other CliError) are already typed.
63
+ if (error instanceof CliError)
64
+ return error;
65
+ if (error instanceof Error)
66
+ return new NetworkError(`Data request failed: ${redact(error.message)}`);
67
+ return new NetworkError('Data request failed');
68
+ }
@@ -0,0 +1,9 @@
1
+ export interface JsonRequest {
2
+ body?: unknown;
3
+ method: string;
4
+ query?: Record<string, boolean | number | string | undefined>;
5
+ timeoutMs?: number;
6
+ token?: null | string;
7
+ url: string;
8
+ }
9
+ export declare function requestJson(req: JsonRequest): Promise<unknown>;
@@ -0,0 +1,55 @@
1
+ import { DataHttpError, NetworkError, TimeoutError } from '../errors.js';
2
+ const DEFAULT_TIMEOUT_MS = 30_000;
3
+ function withQuery(url, query) {
4
+ if (!query)
5
+ return url;
6
+ const params = new URLSearchParams();
7
+ for (const [key, value] of Object.entries(query)) {
8
+ if (value !== undefined)
9
+ params.set(key, String(value));
10
+ }
11
+ const qs = params.toString();
12
+ return qs ? `${url}?${qs}` : url;
13
+ }
14
+ // The single shared request core for first-party API calls (data + admin). Sends
15
+ // JSON, attaches the bearer in-process only, and aborts on timeout. Returns the
16
+ // parsed JSON on 2xx (raw text if the body is not JSON); throws
17
+ // DataHttpError(status, body) on non-2xx, TimeoutError on abort, NetworkError on a
18
+ // transport failure. It carries NO entity/query semantics — method, url, body and
19
+ // query are entirely the caller's.
20
+ export async function requestJson(req) {
21
+ const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;
22
+ const headers = { accept: 'application/json' };
23
+ if (req.body !== undefined)
24
+ headers['content-type'] = 'application/json';
25
+ if (req.token)
26
+ headers.authorization = `Bearer ${req.token}`;
27
+ const controller = new AbortController();
28
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
29
+ let response;
30
+ try {
31
+ response = await fetch(withQuery(req.url, req.query), {
32
+ body: req.body === undefined ? undefined : JSON.stringify(req.body),
33
+ headers,
34
+ method: req.method,
35
+ signal: controller.signal,
36
+ });
37
+ }
38
+ catch (error) {
39
+ if (controller.signal.aborted)
40
+ throw new TimeoutError(`Request timed out after ${timeoutMs}ms`);
41
+ throw new NetworkError(`Request failed: ${error.message}`);
42
+ }
43
+ finally {
44
+ clearTimeout(timer);
45
+ }
46
+ const text = await response.text();
47
+ if (response.status < 200 || response.status >= 300)
48
+ throw new DataHttpError(response.status, text);
49
+ try {
50
+ return JSON.parse(text);
51
+ }
52
+ catch {
53
+ return text;
54
+ }
55
+ }
@@ -0,0 +1 @@
1
+ export declare function assertUuid(id: string, label?: string): void;
@@ -0,0 +1,8 @@
1
+ import { UsageError } from '../errors.js';
2
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+ // Validate a UUID-shaped id BEFORE any request (a malformed id is a usage error,
4
+ // exit 2, with no round trip). Used by the by-id admin commands.
5
+ export function assertUuid(id, label = 'id') {
6
+ if (!UUID_RE.test(id))
7
+ throw new UsageError(`Invalid ${label} — expected a UUID.`);
8
+ }
@@ -0,0 +1,12 @@
1
+ export interface ListEnvelope<T = unknown> {
2
+ count: number;
3
+ records: T[];
4
+ }
5
+ export declare function normalizeList(raw: unknown): ListEnvelope;
6
+ export declare function drainPages(fetchPage: (page: {
7
+ limit: number;
8
+ offset: number;
9
+ }) => Promise<ListEnvelope>, start: {
10
+ limit: number;
11
+ offset: number;
12
+ }): Promise<ListEnvelope>;
@@ -0,0 +1,31 @@
1
+ import { UsageError } from '../errors.js';
2
+ // Normalize the admin list endpoints' TWO response shapes into one envelope:
3
+ // a bare array -> {count: length, records}; {count, records} -> passthrough.
4
+ // Anything else is a deterministic typed error (never a thrown raw object).
5
+ export function normalizeList(raw) {
6
+ if (Array.isArray(raw))
7
+ return { count: raw.length, records: raw };
8
+ if (raw && typeof raw === 'object' && Array.isArray(raw.records)) {
9
+ const obj = raw;
10
+ return { count: typeof obj.count === 'number' ? obj.count : obj.records.length, records: obj.records };
11
+ }
12
+ throw new UsageError('Unexpected list response shape from the admin API.', 'unexpected_list_shape');
13
+ }
14
+ // Drain pages until a short final page (or `count` reached), concatenating
15
+ // records. Issues no extra empty request. `fetchPage` returns a normalized
16
+ // envelope for a given offset/limit.
17
+ export async function drainPages(fetchPage, start) {
18
+ const records = [];
19
+ let { offset } = start;
20
+ let count = 0;
21
+ for (;;) {
22
+ // eslint-disable-next-line no-await-in-loop -- pages are inherently sequential
23
+ const page = await fetchPage({ limit: start.limit, offset });
24
+ count = page.count;
25
+ records.push(...page.records);
26
+ if (page.records.length < start.limit || records.length >= count)
27
+ break;
28
+ offset += start.limit;
29
+ }
30
+ return { count, records };
31
+ }
@@ -0,0 +1,2 @@
1
+ export declare function projectWorkspace(raw: unknown): Record<string, unknown>;
2
+ export declare function projectConnection(raw: unknown): Record<string, unknown>;
@@ -0,0 +1,34 @@
1
+ import { redact } from '../output/redact.js';
2
+ const WORKSPACE_CORE = ['organization_id', 'slug', 'business_type', 'client_main_goal', 'created_on'];
3
+ const CONNECTION_CORE = ['name', 'client_id', 'type', 'active', 'backfill', 'data_refresh_status', 'created_on'];
4
+ // Project a raw admin Client into a stable "workspace" shape: id + name + present
5
+ // core fields + owner_email (from the nested owner) + a redacted `raw` passthrough
6
+ // so it survives schema drift. Adds/invents no fields.
7
+ export function projectWorkspace(raw) {
8
+ const client = (raw ?? {});
9
+ const out = { id: client.id, name: client.name };
10
+ for (const key of WORKSPACE_CORE)
11
+ if (client[key] !== undefined)
12
+ out[key] = client[key];
13
+ const owner = client.owner;
14
+ if (owner?.email)
15
+ out.owner_email = owner.email;
16
+ out.raw = redact(raw);
17
+ return out;
18
+ }
19
+ // Project a raw Connection into a stable shape: id + present source metadata + a
20
+ // redacted `raw` (so secret_id/secret/parameters are masked by E7-0b).
21
+ export function projectConnection(raw) {
22
+ const conn = (raw ?? {});
23
+ const out = { id: conn.id };
24
+ for (const key of CONNECTION_CORE)
25
+ if (conn[key] !== undefined)
26
+ out[key] = conn[key];
27
+ // Surface the owning workspace's name from a nested `client` when present (the
28
+ // command resolves it from a workspace lookup otherwise).
29
+ const client = conn.client;
30
+ if (client?.name)
31
+ out.client_name = client.name;
32
+ out.raw = redact(raw);
33
+ return out;
34
+ }
@@ -0,0 +1,13 @@
1
+ import { type DataClient } from './client.js';
2
+ export interface RefreshDeps {
3
+ accessTokenIfFresh: (authHost: string) => null | string;
4
+ authHost: string;
5
+ refresh: (authHost: string) => Promise<null | string>;
6
+ }
7
+ export declare function withFreshToken<T>(deps: RefreshDeps, fn: (token: string) => Promise<T>): Promise<T>;
8
+ export interface AuthedDataClientDeps extends RefreshDeps {
9
+ host?: string;
10
+ queryPath?: string;
11
+ timeoutMs?: number;
12
+ }
13
+ export declare function createAuthedDataClient(deps: AuthedDataClientDeps): DataClient;
@@ -0,0 +1,37 @@
1
+ import { DATA_HOST } from '../config/constants.js';
2
+ import { AuthRequiredError, DataHttpError } from '../errors.js';
3
+ import { requestJson } from './http.js';
4
+ // The single authoritative 401 -> refresh -> retry-once wrapper, METHOD-AGNOSTIC:
5
+ // it runs `fn(token)` for ANY request (GET or POST). It proactively silent-
6
+ // refreshes when the cached token is stale, and on a 401 (DataHttpError) it
7
+ // refreshes EXACTLY once and retries EXACTLY once. A null refresh surfaces a typed
8
+ // session_expired (exit 4). Non-401 errors (403/5xx/network/timeout) are never
9
+ // retried; a second consecutive 401 propagates. Refresh always targets authHost.
10
+ export async function withFreshToken(deps, fn) {
11
+ let token = deps.accessTokenIfFresh(deps.authHost) ?? (await deps.refresh(deps.authHost));
12
+ if (!token)
13
+ throw AuthRequiredError.sessionExpired();
14
+ try {
15
+ return await fn(token);
16
+ }
17
+ catch (error) {
18
+ if (!(error instanceof DataHttpError) || error.status !== 401)
19
+ throw error;
20
+ token = await deps.refresh(deps.authHost);
21
+ if (!token)
22
+ throw AuthRequiredError.sessionExpired();
23
+ return fn(token);
24
+ }
25
+ }
26
+ // The authed DATA client: withFreshToken wrapped around a POST of the opaque body
27
+ // to the data host's /query. (The admin client, E7-1a, wraps GETs with the same
28
+ // withFreshToken — one refresh/retry implementation for both.)
29
+ export function createAuthedDataClient(deps) {
30
+ const url = `${(deps.host ?? DATA_HOST).replace(/\/+$/, '')}${deps.queryPath ?? '/query'}`;
31
+ const defaultTimeout = deps.timeoutMs ?? 30_000;
32
+ return {
33
+ postQuery(body, callOptions = {}) {
34
+ return withFreshToken(deps, (token) => requestJson({ body, method: 'POST', timeoutMs: callOptions.timeoutMs ?? defaultTimeout, token, url }));
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,3 @@
1
+ import type { TokenStore } from '../store/index.js';
2
+ export declare function decodeJwtClaimsUnverified(accessToken: string): null | Record<string, unknown>;
3
+ export declare function tokenClaims(host: string, store: TokenStore): null | Record<string, unknown>;
@@ -0,0 +1,20 @@
1
+ // Decode (WITHOUT verifying the signature) a JWT payload — DISPLAY ONLY.
2
+ // MUST NOT be used for any authorization / request-gating decision (the CLI is a
3
+ // thin client; the server validates tokens). A convention test guards this.
4
+ export function decodeJwtClaimsUnverified(accessToken) {
5
+ const parts = accessToken.split('.');
6
+ if (parts.length < 2)
7
+ return null;
8
+ try {
9
+ const padded = parts[1] + '='.repeat((4 - (parts[1].length % 4)) % 4);
10
+ return JSON.parse(Buffer.from(padded, 'base64url').toString('utf8'));
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ // Decoded claims of the stored access token for `host` (display only), or null.
17
+ export function tokenClaims(host, store) {
18
+ const creds = store.load(host);
19
+ return creds ? decodeJwtClaimsUnverified(creds.accessToken) : null;
20
+ }
@@ -0,0 +1,13 @@
1
+ export { DiscoveryError } from '../errors.js';
2
+ export interface Metadata {
3
+ authorizationEndpoint: string;
4
+ issParameterSupported?: boolean;
5
+ issuer: string;
6
+ registrationEndpoint: string;
7
+ revocationEndpoint: string;
8
+ tokenEndpoint: string;
9
+ }
10
+ export declare function metadataFromDoc(host: string, doc: Record<string, unknown>): Metadata;
11
+ export declare function fetchMetadata(host: string, options?: {
12
+ timeoutMs?: number;
13
+ }): Promise<Metadata>;
@@ -0,0 +1,62 @@
1
+ import { DiscoveryError } from '../errors.js';
2
+ export { DiscoveryError } from '../errors.js';
3
+ function rstrip(host) {
4
+ return host.replace(/\/+$/, '');
5
+ }
6
+ function asString(value, fallback) {
7
+ return typeof value === 'string' && value.length > 0 ? value : fallback;
8
+ }
9
+ // Build Metadata from a discovery doc with per-field fallback to the conventional
10
+ // /oauth/* paths. Rejects only when code_challenge_methods_supported is PRESENT
11
+ // and lacks S256 (the real v0.0.61 doc omits it — skip-on-absent).
12
+ export function metadataFromDoc(host, doc) {
13
+ const methods = doc.code_challenge_methods_supported;
14
+ if (Array.isArray(methods) && !methods.includes('S256')) {
15
+ throw new DiscoveryError('Server does not advertise PKCE S256 support');
16
+ }
17
+ const base = rstrip(host);
18
+ const iss = doc.authorization_response_iss_parameter_supported;
19
+ return {
20
+ authorizationEndpoint: asString(doc.authorization_endpoint, `${base}/oauth/authorize`),
21
+ issParameterSupported: typeof iss === 'boolean' ? iss : undefined,
22
+ issuer: asString(doc.issuer, base),
23
+ registrationEndpoint: asString(doc.registration_endpoint, `${base}/oauth/register`),
24
+ revocationEndpoint: asString(doc.revocation_endpoint, `${base}/oauth/revoke`),
25
+ tokenEndpoint: asString(doc.token_endpoint, `${base}/oauth/token`),
26
+ };
27
+ }
28
+ // Fetch the RFC 8414 discovery document and resolve Metadata. On 404 falls back
29
+ // to the full conventional doc; surfaces typed DiscoveryError for 5xx, non-JSON,
30
+ // timeout, and unreachable host.
31
+ export async function fetchMetadata(host, options = {}) {
32
+ const base = rstrip(host);
33
+ const url = `${base}/.well-known/oauth-authorization-server`;
34
+ const timeoutMs = options.timeoutMs ?? 10_000;
35
+ const controller = new AbortController();
36
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
37
+ let response;
38
+ try {
39
+ response = await fetch(url, { headers: { accept: 'application/json' }, signal: controller.signal });
40
+ }
41
+ catch (error) {
42
+ const reason = controller.signal.aborted ? `timed out after ${timeoutMs}ms` : error.message;
43
+ throw new DiscoveryError(`Could not reach ${base}: ${reason}`);
44
+ }
45
+ finally {
46
+ clearTimeout(timer);
47
+ }
48
+ if (response.status === 404) {
49
+ return metadataFromDoc(base, {});
50
+ }
51
+ if (!response.ok) {
52
+ throw new DiscoveryError(`Discovery failed: HTTP ${response.status}`);
53
+ }
54
+ let doc;
55
+ try {
56
+ doc = (await response.json());
57
+ }
58
+ catch {
59
+ throw new DiscoveryError('Discovery document is not valid JSON');
60
+ }
61
+ return metadataFromDoc(base, doc);
62
+ }