Files
inkreach-teable-cli/src/lib/client.ts
T
yeuimu 3eb66513ea feat(plugin): developer whitelist CLI + idempotent backend
- CLI: plugin dev list/allow/revoke (userId or email)
- Backend: PluginDeveloperService.addDeveloper is now idempotent
  (returns existing record instead of unique-constraint 500)
- Backend: removeDeveloper silently no-ops on P2025 (not found)
- Controller: route api/plugin-developers (avoids :pluginId collision),
  @DisabledPermission so admin check is the sole gate
2026-08-13 15:07:32 +00:00

364 lines
12 KiB
TypeScript

/**
* High-level API client. Each method maps to one REST endpoint of the
* inkreach-teable backend, using the SELF-BUILT contract (not official teable).
*
* Base/table/field/record/view endpoints are compatible with official teable
* open-api; workflow/automation endpoints use the project's own contract.
*/
import { request, ApiError } from './request';
import { IConfig } from './config';
export class Client {
constructor(private cfg: IConfig) {}
private get headers(): Record<string, string> {
return this.cfg.token ? { Authorization: `Bearer ${this.cfg.token}` } : {};
}
private async call<T>(method: string, p: string, body?: unknown): Promise<T> {
const res = await request<T>(this.cfg.endpoint, p, { method, headers: this.headers, body });
return res.data;
}
// ---- auth / user ----
getMe() {
// GET /api/auth/user — @TokenAccess() enabled, works with PAT.
// (/api/auth/user/me is session-only and 403s for PAT.)
return this.call<unknown>('GET', '/api/auth/user');
}
// ---- access tokens (PAT) ----
listAccessTokens() {
return this.call<unknown[]>('GET', '/api/access-token');
}
createAccessToken(body: {
name: string;
description?: string;
scopes: string[];
baseIds?: string[];
spaceIds?: string[];
hasFullAccess?: boolean;
expiredTime: string;
}) {
return this.call<{ id: string; token: string }>('POST', '/api/access-token', body);
}
deleteAccessToken(id: string) {
return this.call<{ id: string }>('DELETE', `/api/access-token/${id}`);
}
// ---- base ----
listBases() {
// GET /api/base/access/all — lists bases the user can access.
return this.call<unknown[]>('GET', '/api/base/access/all');
}
getBase(baseId: string) {
return this.call<unknown>('GET', `/api/base/${baseId}`);
}
// ---- table ----
listTables(baseId: string) {
return this.call<unknown[]>('GET', `/api/base/${baseId}/table`);
}
getTable(tableId: string) {
return this.call<unknown>('GET', `/api/table/${tableId}`);
}
createTable(baseId: string, body: unknown) {
return this.call<unknown>('POST', `/api/base/${baseId}/table`, body);
}
updateTable(tableId: string, body: unknown) {
return this.call<unknown>('PUT', `/api/table/${tableId}`, body);
}
deleteTable(tableId: string, baseId?: string) {
const base = baseId ?? this.cfg.baseId;
return this.call<{ id: string }>('DELETE', `/api/base/${base}/table/${tableId}`);
}
// ---- field ----
listFields(tableId: string) {
return this.call<unknown[]>('GET', `/api/table/${tableId}/field`);
}
getField(fieldId: string) {
return this.call<unknown>('GET', `/api/field/${fieldId}`);
}
createField(tableId: string, body: unknown) {
return this.call<unknown>('POST', `/api/table/${tableId}/field`, body);
}
updateField(fieldId: string, body: unknown) {
return this.call<unknown>('PUT', `/api/field/${fieldId}`, body);
}
deleteField(fieldId: string) {
return this.call<{ id: string }>('DELETE', `/api/field/${fieldId}`);
}
// ---- record ----
getRecords(tableId: string, query?: Record<string, unknown>) {
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
return this.call<unknown>('GET', `/api/table/${tableId}/record${qs}`);
}
/** Flatten search[] tuple arrays into repeated query keys (Teable expects search[]=...). */
private flattenQuery(query: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(query)) {
if (v === undefined || v === null) continue;
if (Array.isArray(v)) {
v.forEach((item) => {
out[`${k}[]`] = typeof item === 'object' ? JSON.stringify(item) : String(item);
});
} else if (typeof v === 'object') {
out[k] = JSON.stringify(v);
} else if (k === 'search' && typeof v === 'string') {
// The records list API expects `search[]=value` (tuple), not a plain
// `search=value` string. https://github.com/colinhacks/zod
out['search[]'] = v;
} else {
out[k] = String(v);
}
}
return out;
}
getRecord(recordId: string, query?: Record<string, unknown>) {
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
return this.call<unknown>('GET', `/api/record/${recordId}${qs}`);
}
createRecord(tableId: string, body: unknown) {
return this.call<unknown>('POST', `/api/table/${tableId}/record`, body);
}
updateRecord(tableId: string, body: unknown) {
return this.call<unknown>('PATCH', `/api/table/${tableId}/record`, body);
}
updateRecordOne(recordId: string, body: unknown) {
return this.call<unknown>('PATCH', `/api/record/${recordId}`, body);
}
deleteRecords(tableId: string, recordIds: string[]) {
const qs = recordIds.map((id) => `recordIds=${encodeURIComponent(id)}`).join('&');
return this.call<unknown>('DELETE', `/api/table/${tableId}/record?${qs}`);
}
// ---- view ----
listViews(tableId: string) {
return this.call<unknown[]>('GET', `/api/table/${tableId}/view`);
}
getView(viewId: string) {
return this.call<unknown>('GET', `/api/view/${viewId}`);
}
createView(tableId: string, body: unknown) {
return this.call<unknown>('POST', `/api/table/${tableId}/view`, body);
}
updateView(viewId: string, body: unknown) {
return this.call<unknown>('PATCH', `/api/view/${viewId}`, body);
}
deleteView(viewId: string) {
return this.call<{ id: string }>('DELETE', `/api/view/${viewId}`);
}
// ---- workflow (self-built contract) ----
listWorkflows(baseId: string) {
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow`);
}
getWorkflow(workflowId: string) {
return this.call<IWorkflowVo>('GET', `/api/base/any/workflow/${workflowId}`);
}
/**
* Get workflow by explicit baseId + workflowId. getWorkflow() above hits a
* route that resolves base from the workflow itself, but some auth/permission
* paths need the base-scoped route. Prefer this when baseId is known.
*/
getWorkflowInBase(baseId: string, workflowId: string) {
return this.call<IWorkflowVo>('GET', `/api/base/${baseId}/workflow/${workflowId}`);
}
createWorkflow(baseId: string, body: { name: string; description?: string }) {
return this.call<IWorkflowVo>('POST', `/api/base/${baseId}/workflow`, body);
}
updateWorkflow(
baseId: string,
workflowId: string,
body: { name?: string; description?: string }
) {
return this.call<IWorkflowVo>('PATCH', `/api/base/${baseId}/workflow/${workflowId}`, body);
}
deleteWorkflow(baseId: string, workflowId: string) {
return this.call<unknown>('DELETE', `/api/base/${baseId}/workflow/${workflowId}`);
}
activateWorkflow(baseId: string, workflowId: string) {
return this.call<{ snapshotId: string }>(
'POST',
`/api/base/${baseId}/workflow/${workflowId}/activate`
);
}
deactivateWorkflow(baseId: string, workflowId: string) {
return this.call<unknown>('POST', `/api/base/${baseId}/workflow/${workflowId}/deactivate`);
}
testWorkflow(baseId: string, workflowId: string) {
return this.call<{ nodeResults: unknown[] }>(
'POST',
`/api/base/${baseId}/workflow/${workflowId}/test`
);
}
/** PUT /node — whole-graph replacement (delete-all + create-all in a txn). */
replaceNodes(
baseId: string,
workflowId: string,
body: { nodes: IWorkflowNodeVo[]; edges: IWorkflowEdgeVo[] }
) {
return this.call<IWorkflowVo>('PUT', `/api/base/${baseId}/workflow/${workflowId}/node`, body);
}
// ---- secrets ----
listSecrets(baseId: string, workflowId: string) {
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow/${workflowId}/secret`);
}
createSecret(
baseId: string,
workflowId: string,
body: { name: string; description?: string; value: string }
) {
return this.call<unknown>('POST', `/api/base/${baseId}/workflow/${workflowId}/secret`, body);
}
updateSecret(
baseId: string,
workflowId: string,
secretId: string,
body: { name?: string; description?: string; value?: string }
) {
return this.call<unknown>(
'PATCH',
`/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`,
body
);
}
deleteSecret(baseId: string, workflowId: string, secretId: string) {
return this.call<unknown>(
'DELETE',
`/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`
);
}
// ---- execution history ----
listExecutions(baseId: string, workflowId: string) {
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow/${workflowId}/execution`);
}
getExecution(baseId: string, workflowId: string, executionId: string) {
return this.call<unknown>(
'GET',
`/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}`
);
}
rerunExecution(baseId: string, workflowId: string, executionId: string) {
return this.call<unknown>(
'POST',
`/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}/rerun`
);
}
// ---- trigger (manual fire for testing) ----
fireButtonClick(tableId: string, recordId: string, fieldId: string) {
return this.call<{ runId: string; tableId: string; fieldId: string; record: unknown }>(
'POST',
`/api/table/${tableId}/record/${recordId}/${fieldId}/button-click`
);
}
fireWebhook(baseId: string, workflowId: string, body?: unknown) {
// NOTE: real backend route is /api/webhook/{baseId}/{workflowId} (no /base/ /workflow/ segments)
return this.call<unknown>('POST', `/api/webhook/${baseId}/${workflowId}`, body);
}
// ---- SQL (read-only) ----
sql(baseId: string, sql: string) {
return this.call<unknown[]>('POST', `/api/base/${baseId}/sql`, { sql });
}
// ---- plugin ----
getPlugins() {
return this.call<unknown[]>('GET', '/api/plugin');
}
registerPlugin(body: Record<string, unknown>) {
return this.call<unknown>('POST', '/api/plugin', body);
}
listPluginConfigs(baseId: string) {
return this.call<unknown[]>('GET', `/api/base/${baseId}/plugin-config`);
}
upsertPluginConfig(baseId: string, pluginId: string, body: Record<string, unknown>) {
return this.call<unknown>('PUT', `/api/base/${baseId}/plugin-config/${pluginId}`, body);
}
// ---- plugin installs ----
listInstalls(baseId: string) {
return this.call<unknown[]>('GET', `/api/base/${baseId}/plugin-installs`);
}
createInstall(baseId: string, body: Record<string, unknown>) {
return this.call<unknown>('POST', `/api/base/${baseId}/plugin-installs`, body);
}
deleteInstall(baseId: string, installId: string) {
return this.call<unknown>('DELETE', `/api/base/${baseId}/plugin-installs/${installId}`);
}
// ---- plugin developers ----
listDevelopers() {
return this.call<unknown[]>('GET', '/api/plugin-developers');
}
addDeveloper(userIdOrEmail: string) {
// Backend reads @Body('userId') or @Body('email'). Send whichever looks right.
const isEmail = userIdOrEmail.includes('@');
const body = isEmail ? { email: userIdOrEmail } : { userId: userIdOrEmail };
return this.call<unknown>('POST', '/api/plugin-developers', body);
}
removeDeveloper(userId: string) {
return this.call<unknown>('DELETE', `/api/plugin-developers/${encodeURIComponent(userId)}`);
}
}
// ---- shared types (mirror backend workflow_node / workflow_edge shapes) ----
export type WorkflowNodeType = 'trigger' | 'action' | 'control';
export type WorkflowNodeCategory =
| 'recordCreated'
| 'recordUpdated'
| 'recordCondition'
| 'recordCreatedOrUpdated'
| 'formSubmitted'
| 'scheduledTime'
| 'buttonClick'
| 'webhook'
| 'script'
| 'sendEmail'
| 'httpRequest'
| 'createRecord'
| 'updateRecord'
| 'getRecords'
| 'triggerEnd';
export interface IWorkflowNodeVo {
id: string;
workflowId?: string;
type: WorkflowNodeType;
category: WorkflowNodeCategory | string;
config: Record<string, unknown> | null;
name?: string | null;
testResult?: Record<string, unknown> | null;
inputVariables?: unknown;
outputVariables?: unknown;
}
export interface IWorkflowEdgeVo {
id: string;
workflowId?: string;
sourceNodeId: string;
targetNodeId: string;
}
export interface IWorkflowVo {
id: string;
baseId: string;
name: string;
description?: string | null;
isActive: boolean;
hasDraft: boolean;
nodes: IWorkflowNodeVo[];
edges: IWorkflowEdgeVo[];
createdBy?: string;
createdTime?: string;
lastModifiedBy?: string | null;
lastModifiedTime?: string | null;
}
export { ApiError };