@possibl/rcrt-sdk 0.1.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.
- package/CHANGELOG.md +56 -0
- package/README.md +154 -0
- package/package.json +54 -0
- package/src/auth.ts +56 -0
- package/src/authn.ts +159 -0
- package/src/breadcrumbs.ts +111 -0
- package/src/capabilities.ts +93 -0
- package/src/cards.ts +110 -0
- package/src/chat.ts +83 -0
- package/src/client.ts +97 -0
- package/src/errors.ts +101 -0
- package/src/files.ts +135 -0
- package/src/grants.ts +99 -0
- package/src/index.ts +103 -0
- package/src/internal/fetch.ts +133 -0
- package/src/internal/sse.ts +236 -0
- package/src/sessions.ts +110 -0
- package/src/types/breadcrumb.ts +77 -0
- package/src/types/card.ts +298 -0
- package/src/types/index.ts +2 -0
package/src/cards.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cards module — resolve an `interpret:pending-action` breadcrumb.
|
|
3
|
+
*
|
|
4
|
+
* The card's footer action ids correspond to values the SDK writes
|
|
5
|
+
* to `content.status`. See
|
|
6
|
+
* `packages/docs/guides/06-rendering-cards.md`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { FetchContext } from './internal/fetch.js';
|
|
10
|
+
import { request } from './internal/fetch.js';
|
|
11
|
+
import type { Breadcrumb } from './types/breadcrumb.js';
|
|
12
|
+
import type { Card, ResolveRequest } from './types/card.js';
|
|
13
|
+
import { ApiError } from './errors.js';
|
|
14
|
+
|
|
15
|
+
export class CardsModule {
|
|
16
|
+
constructor(private readonly ctx: FetchContext) {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* PATCH the card breadcrumb with a resolution. Handles optimistic
|
|
20
|
+
* locking transparently — refetches + retries on 409.
|
|
21
|
+
*/
|
|
22
|
+
async resolve(breadcrumbId: string, resolution: ResolveRequest): Promise<Breadcrumb> {
|
|
23
|
+
const current = await request<Breadcrumb>(this.ctx, `/v1/breadcrumbs/${breadcrumbId}`);
|
|
24
|
+
const nextContent = {
|
|
25
|
+
...(current.content ?? {}),
|
|
26
|
+
status: resolution.status,
|
|
27
|
+
user_response: resolution.user_response,
|
|
28
|
+
resolved_at: new Date().toISOString(),
|
|
29
|
+
};
|
|
30
|
+
const nextTags = ensureStatusTag(current.tags, resolution.status);
|
|
31
|
+
return request<Breadcrumb>(this.ctx, `/v1/breadcrumbs/${breadcrumbId}`, {
|
|
32
|
+
method: 'PATCH',
|
|
33
|
+
body: {
|
|
34
|
+
version: current.version,
|
|
35
|
+
content: nextContent,
|
|
36
|
+
tags: nextTags,
|
|
37
|
+
},
|
|
38
|
+
maxConflictRetries: 2,
|
|
39
|
+
refetchBeforeRetry: async () => {
|
|
40
|
+
const fresh = await request<Breadcrumb>(this.ctx, `/v1/breadcrumbs/${breadcrumbId}`);
|
|
41
|
+
return {
|
|
42
|
+
body: {
|
|
43
|
+
version: fresh.version,
|
|
44
|
+
content: {
|
|
45
|
+
...(fresh.content ?? {}),
|
|
46
|
+
status: resolution.status,
|
|
47
|
+
user_response: resolution.user_response,
|
|
48
|
+
resolved_at: new Date().toISOString(),
|
|
49
|
+
},
|
|
50
|
+
tags: ensureStatusTag(fresh.tags, resolution.status),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Pending cards for the current user's current workspace. */
|
|
58
|
+
async listPending(limit = 50): Promise<Breadcrumb[]> {
|
|
59
|
+
const list = await request<Breadcrumb[] | { breadcrumbs: Breadcrumb[] }>(this.ctx, '/v1/breadcrumbs', {
|
|
60
|
+
query: { tags: 'interpret:pending-action,status:pending', limit },
|
|
61
|
+
});
|
|
62
|
+
return Array.isArray(list) ? list : (list.breadcrumbs ?? []);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract the Card object from a breadcrumb's content, normalising
|
|
67
|
+
* over legacy shapes (old breadcrumbs stored `card.type` + flat
|
|
68
|
+
* fields instead of `card.layout` + structured body).
|
|
69
|
+
*/
|
|
70
|
+
static extractCard(bc: Breadcrumb): Card | undefined {
|
|
71
|
+
const content = (bc.content ?? {}) as Record<string, unknown>;
|
|
72
|
+
const raw = (content.card as Record<string, unknown> | undefined) ?? undefined;
|
|
73
|
+
if (!raw) return undefined;
|
|
74
|
+
if (typeof raw.layout === 'string') return raw as unknown as Card;
|
|
75
|
+
// Legacy — fall back to a minimal info card.
|
|
76
|
+
if (typeof raw.type === 'string') {
|
|
77
|
+
const card: Card = {
|
|
78
|
+
layout: legacyTypeToLayout(raw.type as string),
|
|
79
|
+
header: { title: typeof content.title === 'string' ? content.title : bc.title },
|
|
80
|
+
};
|
|
81
|
+
if (typeof content.summary === 'string') {
|
|
82
|
+
card.body = { text: content.summary };
|
|
83
|
+
}
|
|
84
|
+
return card;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function ensureStatusTag(existing: string[], status: string): string[] {
|
|
91
|
+
const withoutStatus = existing.filter((t) => !t.startsWith('status:'));
|
|
92
|
+
withoutStatus.push(`status:${status}`);
|
|
93
|
+
return withoutStatus;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function legacyTypeToLayout(type: string): Card['layout'] {
|
|
97
|
+
switch (type) {
|
|
98
|
+
case 'connect':
|
|
99
|
+
return 'connect';
|
|
100
|
+
case 'confirm':
|
|
101
|
+
case 'choice':
|
|
102
|
+
case 'multi-choice':
|
|
103
|
+
case 'approval':
|
|
104
|
+
return 'decision';
|
|
105
|
+
case 'text-input':
|
|
106
|
+
return 'input';
|
|
107
|
+
default:
|
|
108
|
+
return 'info';
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/chat.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat module — `POST /v1/chat` + per-session / global SSE streams.
|
|
3
|
+
*
|
|
4
|
+
* See `packages/docs/guides/03-chat-and-sse.md`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FetchContext } from './internal/fetch.js';
|
|
8
|
+
import { request } from './internal/fetch.js';
|
|
9
|
+
import type { SseConnection, SseConnectConfig, SseHandlers } from './internal/sse.js';
|
|
10
|
+
import { connect as sseConnect } from './internal/sse.js';
|
|
11
|
+
|
|
12
|
+
export interface SendChatRequest {
|
|
13
|
+
message: string;
|
|
14
|
+
/** The agent to route this turn to. `life-coordinator` by default in the Ritual bundle. */
|
|
15
|
+
target_agent: string;
|
|
16
|
+
/** Resume an existing session; omit to start a new one. */
|
|
17
|
+
session_id?: string;
|
|
18
|
+
/** Extra tags stamped on the user's message breadcrumb. */
|
|
19
|
+
extra_tags?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SendChatResponse {
|
|
23
|
+
id: string;
|
|
24
|
+
session_id: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SseStreamOptions {
|
|
28
|
+
/** Override the EventSource constructor (required in React Native). */
|
|
29
|
+
eventSource?: SseConnectConfig['eventSource'];
|
|
30
|
+
useHeaderAuth?: boolean;
|
|
31
|
+
maxBackoffMs?: number;
|
|
32
|
+
/** Supply a Last-Event-ID-style resume token if your server supports it. */
|
|
33
|
+
last_event_id?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class ChatModule {
|
|
37
|
+
constructor(private readonly ctx: FetchContext) {}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Send a user message to an agent. Returns immediately with the
|
|
41
|
+
* breadcrumb id + session id. The agent reply arrives via the SSE
|
|
42
|
+
* stream — call `stream(session_id)` before posting, not after.
|
|
43
|
+
*/
|
|
44
|
+
async send(req: SendChatRequest): Promise<SendChatResponse> {
|
|
45
|
+
return request<SendChatResponse>(this.ctx, '/v1/chat', {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
body: req,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Per-session SSE — just this session's events. Recommended for chat views. */
|
|
52
|
+
sessionStream(sessionId: string, handlers: SseHandlers, options: SseStreamOptions = {}): SseConnection {
|
|
53
|
+
return sseConnect(this.buildConfig(`/v1/sessions/${sessionId}/stream`, options), handlers);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Global SSE — everything the user can see. Useful for home feeds / awaiting-you. */
|
|
57
|
+
globalStream(handlers: SseHandlers, options: SseStreamOptions = {}): SseConnection {
|
|
58
|
+
return sseConnect(this.buildConfig('/v1/events', options), handlers);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private buildConfig(path: string, options: SseStreamOptions): SseConnectConfig {
|
|
62
|
+
const config: SseConnectConfig = {
|
|
63
|
+
apiUrl: this.ctx.apiUrl,
|
|
64
|
+
path,
|
|
65
|
+
tenantId: this.ctx.tenantId,
|
|
66
|
+
getToken: () => this.ctx.tokenProvider.getIdToken(),
|
|
67
|
+
};
|
|
68
|
+
if (options.eventSource) config.eventSource = options.eventSource;
|
|
69
|
+
if (options.useHeaderAuth !== undefined) config.useHeaderAuth = options.useHeaderAuth;
|
|
70
|
+
if (options.maxBackoffMs !== undefined) config.maxBackoffMs = options.maxBackoffMs;
|
|
71
|
+
if (options.last_event_id) config.query = { last_event_id: options.last_event_id };
|
|
72
|
+
return config;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Clear a session's suspend flag after the loop detector fired.
|
|
77
|
+
*
|
|
78
|
+
* @see `packages/docs/operations/loop-detector.md`
|
|
79
|
+
*/
|
|
80
|
+
async resumeSession(sessionId: string): Promise<void> {
|
|
81
|
+
await request<void>(this.ctx, `/v1/sessions/${sessionId}/resume`, { method: 'POST' });
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RcrtClient — the single entry-point to the SDK.
|
|
3
|
+
*
|
|
4
|
+
* import { RcrtClient, staticTokenProvider } from '@possibl/rcrt-sdk';
|
|
5
|
+
*
|
|
6
|
+
* const rcrt = new RcrtClient({
|
|
7
|
+
* apiUrl: 'https://rcrt-api-gateway-<hash>.run.app',
|
|
8
|
+
* tokenProvider: firebaseTokenProvider(auth),
|
|
9
|
+
* });
|
|
10
|
+
*
|
|
11
|
+
* rcrt.setTenantId(workspaceId);
|
|
12
|
+
* const me = await rcrt.auth.me();
|
|
13
|
+
* const stream = rcrt.chat.sessionStream(sessionId, { ...handlers });
|
|
14
|
+
*
|
|
15
|
+
* Every module on the client shares the same fetch context — one
|
|
16
|
+
* bearer refresh path, one tenant header, one error envelope.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { TokenProvider } from './auth.js';
|
|
20
|
+
import type { FetchContext } from './internal/fetch.js';
|
|
21
|
+
import { BreadcrumbsModule } from './breadcrumbs.js';
|
|
22
|
+
import { ChatModule } from './chat.js';
|
|
23
|
+
import { CardsModule } from './cards.js';
|
|
24
|
+
import { GrantsModule } from './grants.js';
|
|
25
|
+
import { IdentityModule } from './authn.js';
|
|
26
|
+
import { FilesModule } from './files.js';
|
|
27
|
+
import { SessionsModule } from './sessions.js';
|
|
28
|
+
import { CapabilitiesModule } from './capabilities.js';
|
|
29
|
+
import { SdkError } from './errors.js';
|
|
30
|
+
|
|
31
|
+
export interface RcrtClientConfig {
|
|
32
|
+
apiUrl: string;
|
|
33
|
+
tokenProvider: TokenProvider;
|
|
34
|
+
/** Optional workspace UUID. Can be set later via `setTenantId()`. */
|
|
35
|
+
tenantId?: string;
|
|
36
|
+
/** Optional fetch override (SSR, custom retry, mock). */
|
|
37
|
+
fetchImpl?: typeof fetch;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class RcrtClient {
|
|
41
|
+
private readonly ctx: FetchContext;
|
|
42
|
+
|
|
43
|
+
public readonly auth: IdentityModule;
|
|
44
|
+
public readonly breadcrumbs: BreadcrumbsModule;
|
|
45
|
+
public readonly chat: ChatModule;
|
|
46
|
+
public readonly cards: CardsModule;
|
|
47
|
+
public readonly grants: GrantsModule;
|
|
48
|
+
public readonly files: FilesModule;
|
|
49
|
+
public readonly sessions: SessionsModule;
|
|
50
|
+
public readonly capabilities: CapabilitiesModule;
|
|
51
|
+
|
|
52
|
+
constructor(config: RcrtClientConfig) {
|
|
53
|
+
if (!config.apiUrl) {
|
|
54
|
+
throw new SdkError('MISSING_API_URL', 'RcrtClient requires `apiUrl`');
|
|
55
|
+
}
|
|
56
|
+
if (!config.tokenProvider) {
|
|
57
|
+
throw new SdkError('MISSING_TOKEN_PROVIDER', 'RcrtClient requires a `tokenProvider`');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ctxInternal = {
|
|
61
|
+
apiUrl: config.apiUrl,
|
|
62
|
+
tenantId: config.tenantId ?? null,
|
|
63
|
+
tokenProvider: config.tokenProvider,
|
|
64
|
+
fetchImpl: config.fetchImpl ?? undefined,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Use a Proxy so module instances can read the current `tenantId`
|
|
68
|
+
// at request time without us having to rebuild them on every
|
|
69
|
+
// `setTenantId` call.
|
|
70
|
+
this.ctx = new Proxy({} as FetchContext, {
|
|
71
|
+
get: (_target, prop) => (ctxInternal as unknown as Record<string, unknown>)[prop as string],
|
|
72
|
+
set: (_target, prop, value) => {
|
|
73
|
+
(ctxInternal as unknown as Record<string, unknown>)[prop as string] = value;
|
|
74
|
+
return true;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
this.auth = new IdentityModule(this.ctx);
|
|
79
|
+
this.breadcrumbs = new BreadcrumbsModule(this.ctx);
|
|
80
|
+
this.chat = new ChatModule(this.ctx);
|
|
81
|
+
this.cards = new CardsModule(this.ctx);
|
|
82
|
+
this.grants = new GrantsModule(this.ctx);
|
|
83
|
+
this.files = new FilesModule(this.ctx);
|
|
84
|
+
this.sessions = new SessionsModule(this.ctx);
|
|
85
|
+
this.capabilities = new CapabilitiesModule(this.ctx);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Switch workspaces. Subsequent requests carry the new `X-Tenant-ID`. */
|
|
89
|
+
setTenantId(tenantId: string | null): void {
|
|
90
|
+
(this.ctx as unknown as { tenantId: string | null }).tenantId = tenantId;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Read the workspace id this client is currently scoped to. */
|
|
94
|
+
getTenantId(): string | null {
|
|
95
|
+
return (this.ctx as unknown as { tenantId: string | null }).tenantId;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error taxonomy.
|
|
3
|
+
*
|
|
4
|
+
* The RCRT backend is mid-migration from a flat `{"error": "string"}`
|
|
5
|
+
* envelope to a typed `{"error": {"code", "message", "details"}}`
|
|
6
|
+
* shape. This module normalises both into one `ApiError` that app
|
|
7
|
+
* code can switch on.
|
|
8
|
+
*
|
|
9
|
+
* See `packages/docs/guides/07-error-handling.md`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Stable machine-readable error codes recognised by the SDK. */
|
|
13
|
+
export type KnownErrorCode =
|
|
14
|
+
| 'UNAUTHORIZED'
|
|
15
|
+
| 'FORBIDDEN'
|
|
16
|
+
| 'NOT_FOUND'
|
|
17
|
+
| 'CONFLICT'
|
|
18
|
+
| 'INVALID_REQUEST'
|
|
19
|
+
| 'TOO_MANY_REQUESTS'
|
|
20
|
+
| 'INTERNAL'
|
|
21
|
+
| 'SERVICE_UNAVAILABLE'
|
|
22
|
+
| 'UNKNOWN';
|
|
23
|
+
|
|
24
|
+
export interface ApiErrorDetail {
|
|
25
|
+
/** Machine-readable code if the server sent one. Otherwise derived from status. */
|
|
26
|
+
code: KnownErrorCode | string;
|
|
27
|
+
message: string;
|
|
28
|
+
details?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class ApiError extends Error {
|
|
32
|
+
public readonly status: number;
|
|
33
|
+
public readonly detail: ApiErrorDetail;
|
|
34
|
+
public readonly rawBody: string | undefined;
|
|
35
|
+
|
|
36
|
+
constructor(status: number, detail: ApiErrorDetail, rawBody?: string) {
|
|
37
|
+
super(detail.message);
|
|
38
|
+
this.name = 'ApiError';
|
|
39
|
+
this.status = status;
|
|
40
|
+
this.detail = detail;
|
|
41
|
+
this.rawBody = rawBody;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static fromResponse(status: number, rawBody: string): ApiError {
|
|
45
|
+
return new ApiError(status, parseErrorBody(status, rawBody), rawBody);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Errors thrown by the SDK before we even reach the server. */
|
|
50
|
+
export class SdkError extends Error {
|
|
51
|
+
public readonly code: string;
|
|
52
|
+
|
|
53
|
+
constructor(code: string, message: string) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = 'SdkError';
|
|
56
|
+
this.code = code;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Internal helpers ─────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const STATUS_TO_CODE: Record<number, KnownErrorCode> = {
|
|
63
|
+
400: 'INVALID_REQUEST',
|
|
64
|
+
401: 'UNAUTHORIZED',
|
|
65
|
+
403: 'FORBIDDEN',
|
|
66
|
+
404: 'NOT_FOUND',
|
|
67
|
+
409: 'CONFLICT',
|
|
68
|
+
429: 'TOO_MANY_REQUESTS',
|
|
69
|
+
500: 'INTERNAL',
|
|
70
|
+
503: 'SERVICE_UNAVAILABLE',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function parseErrorBody(status: number, rawBody: string): ApiErrorDetail {
|
|
74
|
+
const fallbackCode = STATUS_TO_CODE[status] ?? 'UNKNOWN';
|
|
75
|
+
if (!rawBody.trim()) {
|
|
76
|
+
return { code: fallbackCode, message: `HTTP ${status}` };
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(rawBody) as unknown;
|
|
80
|
+
if (parsed && typeof parsed === 'object' && 'error' in parsed) {
|
|
81
|
+
const err = (parsed as { error: unknown }).error;
|
|
82
|
+
if (typeof err === 'string') {
|
|
83
|
+
return { code: fallbackCode, message: err };
|
|
84
|
+
}
|
|
85
|
+
if (err && typeof err === 'object') {
|
|
86
|
+
const e = err as Record<string, unknown>;
|
|
87
|
+
const detail: ApiErrorDetail = {
|
|
88
|
+
code: typeof e.code === 'string' ? e.code : fallbackCode,
|
|
89
|
+
message: typeof e.message === 'string' ? e.message : `HTTP ${status}`,
|
|
90
|
+
};
|
|
91
|
+
if (typeof e.details === 'object' && e.details !== null) {
|
|
92
|
+
detail.details = e.details as Record<string, unknown>;
|
|
93
|
+
}
|
|
94
|
+
return detail;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { code: fallbackCode, message: rawBody.slice(0, 200) };
|
|
98
|
+
} catch {
|
|
99
|
+
return { code: fallbackCode, message: rawBody.slice(0, 200) };
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files module — upload + download + read text + list + delete.
|
|
3
|
+
*
|
|
4
|
+
* Files are stored as breadcrumbs under `interpret:file` so they
|
|
5
|
+
* inherit the same tag / permissions / SSE lifecycle as everything
|
|
6
|
+
* else. The upload returns the file's breadcrumb so callers can
|
|
7
|
+
* round-trip into `breadcrumbs.update()` for renames or tagging.
|
|
8
|
+
*
|
|
9
|
+
* `getDownloadUrl` returns a short-lived signed URL suitable for
|
|
10
|
+
* `<a href>` or `fetch()`; the SDK does not cache it.
|
|
11
|
+
*
|
|
12
|
+
* See `packages/docs/guides/06-files.md` for the narrative.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { FetchContext } from './internal/fetch.js';
|
|
16
|
+
import { request } from './internal/fetch.js';
|
|
17
|
+
import { ApiError, SdkError } from './errors.js';
|
|
18
|
+
import type { Breadcrumb } from './types/breadcrumb.js';
|
|
19
|
+
|
|
20
|
+
export type FileScope = 'tenant' | 'org' | 'user';
|
|
21
|
+
|
|
22
|
+
export interface UploadFileOptions {
|
|
23
|
+
/** Default `'tenant'` — file is visible to all members of the active workspace. */
|
|
24
|
+
scope?: FileScope;
|
|
25
|
+
/** Override the filename written on the breadcrumb (defaults to `File.name`). */
|
|
26
|
+
filename?: string;
|
|
27
|
+
/** Extra tags to attach. `interpret:file` is always added by the server. */
|
|
28
|
+
tags?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ListFilesOptions {
|
|
32
|
+
scope?: FileScope;
|
|
33
|
+
limit?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class FilesModule {
|
|
37
|
+
constructor(private readonly ctx: FetchContext) {}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `POST /v1/files` — multipart upload.
|
|
41
|
+
*
|
|
42
|
+
* Accepts a `Blob` (browser / RN) or `File` (browser only). Server
|
|
43
|
+
* returns the breadcrumb wrapping the stored file.
|
|
44
|
+
*/
|
|
45
|
+
async upload(file: Blob | File, options: UploadFileOptions = {}): Promise<Breadcrumb> {
|
|
46
|
+
const fetchImpl = this.ctx.fetchImpl ?? globalThis.fetch;
|
|
47
|
+
if (typeof fetchImpl !== 'function') {
|
|
48
|
+
throw new SdkError('NO_FETCH', 'fetch() is not available — pass `fetchImpl` in client config');
|
|
49
|
+
}
|
|
50
|
+
if (typeof FormData === 'undefined') {
|
|
51
|
+
throw new SdkError(
|
|
52
|
+
'NO_FORMDATA',
|
|
53
|
+
'FormData is not available in this environment — provide a polyfill (e.g. form-data on Node) before calling files.upload()',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const formData = new FormData();
|
|
58
|
+
const filename =
|
|
59
|
+
options.filename ?? ((file as File).name ? (file as File).name : 'upload');
|
|
60
|
+
formData.append('file', file as Blob, filename);
|
|
61
|
+
formData.append('scope', options.scope ?? 'tenant');
|
|
62
|
+
if (options.tags?.length) {
|
|
63
|
+
formData.append('tags', options.tags.join(','));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const url = `${this.ctx.apiUrl.replace(/\/$/, '')}/v1/files`;
|
|
67
|
+
const idToken = await this.ctx.tokenProvider.getIdToken();
|
|
68
|
+
const headers: Record<string, string> = {
|
|
69
|
+
Authorization: `Bearer ${idToken}`,
|
|
70
|
+
};
|
|
71
|
+
if (this.ctx.tenantId) headers['X-Tenant-ID'] = this.ctx.tenantId;
|
|
72
|
+
|
|
73
|
+
const res = await fetchImpl(url, { method: 'POST', headers, body: formData });
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const raw = await res.text().catch(() => '');
|
|
76
|
+
throw ApiError.fromResponse(res.status, raw || 'upload failed');
|
|
77
|
+
}
|
|
78
|
+
const json = (await res.json()) as { breadcrumb?: Breadcrumb } & Breadcrumb;
|
|
79
|
+
return json.breadcrumb ?? json;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `GET /v1/files?scope=...&limit=...` */
|
|
83
|
+
async list(options: ListFilesOptions = {}): Promise<Breadcrumb[]> {
|
|
84
|
+
const res = await request<{ files: Breadcrumb[] } | Breadcrumb[]>(this.ctx, '/v1/files', {
|
|
85
|
+
query: {
|
|
86
|
+
scope: options.scope,
|
|
87
|
+
limit: options.limit,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
return Array.isArray(res) ? res : (res.files ?? []);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `GET /v1/files/{id}` — wrapping breadcrumb for the file. */
|
|
94
|
+
async get(fileId: string): Promise<Breadcrumb> {
|
|
95
|
+
const res = await request<{ file: Breadcrumb } | Breadcrumb>(this.ctx, `/v1/files/${fileId}`);
|
|
96
|
+
return 'file' in (res as { file: Breadcrumb })
|
|
97
|
+
? (res as { file: Breadcrumb }).file
|
|
98
|
+
: (res as Breadcrumb);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `GET /v1/files/{id}/content?expiry=...` — signed download URL.
|
|
103
|
+
*
|
|
104
|
+
* Default expiry `1h`. URL format goes through Cloud Storage's V4
|
|
105
|
+
* signed URL flow on the server side. Don't cache — re-fetch when
|
|
106
|
+
* you need a fresh one.
|
|
107
|
+
*/
|
|
108
|
+
async getDownloadUrl(fileId: string, expiry: string = '1h'): Promise<string> {
|
|
109
|
+
const res = await request<{ download_url: string }>(this.ctx, `/v1/files/${fileId}/content`, {
|
|
110
|
+
query: { expiry },
|
|
111
|
+
});
|
|
112
|
+
return res.download_url;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `GET /v1/files/{id}/text` — server-side OCR / text extraction.
|
|
117
|
+
*
|
|
118
|
+
* Only works for content the document-parser knows about (PDF,
|
|
119
|
+
* common image formats, plain text). Returns `''` for unparseable.
|
|
120
|
+
*/
|
|
121
|
+
async getText(fileId: string): Promise<string> {
|
|
122
|
+
const res = await request<{ text: string }>(this.ctx, `/v1/files/${fileId}/text`);
|
|
123
|
+
return res.text;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** `DELETE /v1/files/{id}` — soft delete on the wrapping breadcrumb. */
|
|
127
|
+
async delete(fileId: string): Promise<void> {
|
|
128
|
+
try {
|
|
129
|
+
await request<void>(this.ctx, `/v1/files/${fileId}`, { method: 'DELETE' });
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err instanceof ApiError && err.status === 404) return; // idempotent
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/grants.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service grants module — OAuth connect flow + grant management.
|
|
3
|
+
*
|
|
4
|
+
* See `packages/docs/guides/05-connecting-services.md`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FetchContext } from './internal/fetch.js';
|
|
8
|
+
import { request } from './internal/fetch.js';
|
|
9
|
+
|
|
10
|
+
export type GrantType = 'per_user' | 'service_account';
|
|
11
|
+
export type GrantStatus = 'connected' | 'revoked' | 'error';
|
|
12
|
+
|
|
13
|
+
export interface ServiceGrantSummary {
|
|
14
|
+
id: string;
|
|
15
|
+
service_id: string;
|
|
16
|
+
account_label?: string | null;
|
|
17
|
+
email?: string | null;
|
|
18
|
+
scopes: string[];
|
|
19
|
+
status: GrantStatus;
|
|
20
|
+
granted_at: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface InitiateAuthRequest {
|
|
24
|
+
grant_type?: GrantType;
|
|
25
|
+
scopes?: string[];
|
|
26
|
+
/** Where the browser lands after provider consent. */
|
|
27
|
+
redirect_uri?: string;
|
|
28
|
+
/** Label this grant as a specific account (e.g. `work`, `personal`). */
|
|
29
|
+
account_label?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface InitiateAuthResponse {
|
|
33
|
+
auth_url: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class GrantsModule {
|
|
37
|
+
constructor(private readonly ctx: FetchContext) {}
|
|
38
|
+
|
|
39
|
+
/** All grants the current workspace can see. */
|
|
40
|
+
async list(): Promise<ServiceGrantSummary[]> {
|
|
41
|
+
const res = await request<ServiceGrantSummary[] | { grants: ServiceGrantSummary[] }>(
|
|
42
|
+
this.ctx,
|
|
43
|
+
'/v1/service-grants',
|
|
44
|
+
);
|
|
45
|
+
return Array.isArray(res) ? res : (res.grants ?? []);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Grant for one specific service (optionally an account label). */
|
|
49
|
+
async get(serviceId: string, accountLabel?: string): Promise<ServiceGrantSummary | null> {
|
|
50
|
+
const query: Record<string, string | undefined> = {};
|
|
51
|
+
if (accountLabel) query.account = accountLabel;
|
|
52
|
+
const res = await request<ServiceGrantSummary | null>(this.ctx, `/v1/service-grants/${serviceId}`, {
|
|
53
|
+
query,
|
|
54
|
+
});
|
|
55
|
+
return res ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Kick off an OAuth flow. Returns the provider URL to open in a popup / in-app browser. */
|
|
59
|
+
async initiateAuth(serviceId: string, req: InitiateAuthRequest = {}): Promise<InitiateAuthResponse> {
|
|
60
|
+
return request<InitiateAuthResponse>(this.ctx, `/v1/service-grants/${serviceId}/auth/init`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
body: {
|
|
63
|
+
grant_type: req.grant_type ?? 'per_user',
|
|
64
|
+
scopes: req.scopes,
|
|
65
|
+
redirect_uri: req.redirect_uri,
|
|
66
|
+
account_label: req.account_label,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Revoke a grant. Safe + idempotent — revoking a revoked grant is a no-op. */
|
|
72
|
+
async revoke(serviceId: string, accountLabel?: string): Promise<void> {
|
|
73
|
+
const query: Record<string, string | undefined> = {};
|
|
74
|
+
if (accountLabel) query.account = accountLabel;
|
|
75
|
+
await request<void>(this.ctx, `/v1/service-grants/${serviceId}`, {
|
|
76
|
+
method: 'DELETE',
|
|
77
|
+
query,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `GET /v1/services/{name}/resolve` — server-side credential resolution.
|
|
83
|
+
*
|
|
84
|
+
* Used by tools that need to call a third-party API on behalf of
|
|
85
|
+
* the workspace. The server hands back the live credentials for the
|
|
86
|
+
* requested service (Gmail, Notion etc.) keyed off the active grant.
|
|
87
|
+
*
|
|
88
|
+
* Returns `{ service, credentials }`. Treat the credentials as
|
|
89
|
+
* sensitive — don't log them.
|
|
90
|
+
*/
|
|
91
|
+
async resolveService(
|
|
92
|
+
name: string,
|
|
93
|
+
): Promise<{ service: string; credentials: Record<string, unknown> }> {
|
|
94
|
+
return request<{ service: string; credentials: Record<string, unknown> }>(
|
|
95
|
+
this.ctx,
|
|
96
|
+
`/v1/services/${name}/resolve`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|