@c1m-public/control-client 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/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # `@c1m-public/control-client`
2
+
3
+ Typed, server-side transport for C1M control-plane APIs. It exposes platform
4
+ channel operations and workspace-owner channel operations without owning a
5
+ host application's cookie, session, support-mode, geo, or forwarded-header
6
+ policy.
7
+
8
+ ```ts
9
+ import { createControlClient } from "@c1m-public/control-client/next";
10
+
11
+ const control = createControlClient({
12
+ baseUrl: process.env.C1M_API_URL!,
13
+ getAccessToken: async () => (await resolveSession())?.token,
14
+ getHeaders: buildForwardedRequestHeaders,
15
+ });
16
+
17
+ const fleet = await control.platform.channels.list({ status: "active" });
18
+ const owned = await control
19
+ .workspace({ saasApp: "quickprint", workspaceId: "workspace-id" })
20
+ .channels.list();
21
+ ```
22
+
23
+ The npm package is public, but it contains no credentials and grants no access.
24
+ Authorization remains enforced by C1M at the platform/operator or
25
+ workspace-owner boundary. Never import the package into browser components or
26
+ place access tokens in public environment variables.
27
+
28
+ All requests are `no-store`; only GET requests retry transient failures. The
29
+ access token and forwarded headers are resolved for every call so server
30
+ request state cannot leak between users. Observations contain bounded operation
31
+ metadata and never include paths, credentials, headers, bodies, tenant IDs, or
32
+ workspace IDs.
33
+
34
+ ## Release
35
+
36
+ Publish `0.1.0` once manually to create the npm package. Then configure its npm
37
+ Trusted Publisher for GitHub repository `cloud1mediaLtd/mml-js`, workflow
38
+ `publish-control-client.yml`, and push tags named `control-client-vX.Y.Z`.
@@ -0,0 +1,125 @@
1
+ export type * from "./types.js";
2
+ import type { ChannelFilters, ChannelMint, ChannelWebhook, ChannelWebhookDelivery, PlatformChannel, PlatformChannelDetail, WebhookPage, WebhookSecret, WebhookWrite, WorkspaceChannel } from "./types.js";
3
+ export type ControlOperation = `platform.channels.${string}` | `workspace.channels.${string}`;
4
+ export type ControlRequestOutcome = "success" | "api_error" | "network_error" | "timeout" | "invalid_response";
5
+ export interface ControlRequestObservation {
6
+ operation: ControlOperation;
7
+ method: string;
8
+ outcome: ControlRequestOutcome;
9
+ status: number;
10
+ requestId: string;
11
+ durationMs: number;
12
+ attempts: number;
13
+ }
14
+ export interface CreateControlClientOptions {
15
+ baseUrl: string;
16
+ getAccessToken: () => Promise<string | null | undefined>;
17
+ getHeaders?: () => Promise<HeadersInit | undefined>;
18
+ fetch?: typeof globalThis.fetch;
19
+ timeoutMs?: number;
20
+ retryAttempts?: number;
21
+ observe?: (event: ControlRequestObservation) => void;
22
+ }
23
+ export declare class ControlAPIError extends Error {
24
+ readonly status: number;
25
+ readonly code: string;
26
+ readonly requestId: string;
27
+ constructor(message: string, status: number, code: string, requestId: string);
28
+ }
29
+ export declare function createControlClient(options: CreateControlClientOptions): {
30
+ readonly platform: {
31
+ readonly channels: {
32
+ readonly list: (filters?: ChannelFilters) => Promise<{
33
+ items: PlatformChannel[];
34
+ next_cursor?: string;
35
+ has_more: boolean;
36
+ }>;
37
+ readonly get: (id: string) => Promise<PlatformChannelDetail>;
38
+ readonly create: (input: {
39
+ saas_app: string;
40
+ workspace_id: string;
41
+ name: string;
42
+ token_name: string;
43
+ site_url?: string;
44
+ reason: string;
45
+ confirm: boolean;
46
+ }) => Promise<ChannelMint<PlatformChannel>>;
47
+ readonly update: (id: string, input: {
48
+ status: "active" | "disabled";
49
+ reason: string;
50
+ confirm: boolean;
51
+ }) => Promise<PlatformChannel>;
52
+ readonly tokens: {
53
+ readonly mint: (channelId: string, input: {
54
+ name: string;
55
+ reason: string;
56
+ confirm: boolean;
57
+ }) => Promise<ChannelMint<PlatformChannel>>;
58
+ readonly revoke: (channelId: string, tokenId: string, input: {
59
+ reason: string;
60
+ confirm: boolean;
61
+ }) => Promise<void>;
62
+ };
63
+ readonly webhooks: {
64
+ readonly fleet: (filters?: ChannelFilters) => Promise<WebhookPage>;
65
+ readonly list: (channelId: string) => Promise<WebhookPage>;
66
+ readonly create: (channelId: string, input: WebhookWrite) => Promise<WebhookSecret>;
67
+ readonly update: (channelId: string, webhookId: string, input: WebhookWrite) => Promise<ChannelWebhook>;
68
+ readonly delete: (channelId: string, webhookId: string, input: {
69
+ reason: string;
70
+ confirm: boolean;
71
+ }) => Promise<void>;
72
+ readonly revealSecret: (channelId: string, webhookId: string, input: {
73
+ reason: string;
74
+ confirm: boolean;
75
+ }) => Promise<WebhookSecret>;
76
+ readonly deliveries: (channelId: string, webhookId: string, cursor?: string) => Promise<{
77
+ items: ChannelWebhookDelivery[];
78
+ next_cursor?: string;
79
+ has_more: boolean;
80
+ }>;
81
+ readonly replay: (channelId: string, webhookId: string, deliveryId: string, input: {
82
+ reason: string;
83
+ confirm: boolean;
84
+ }) => Promise<void>;
85
+ };
86
+ };
87
+ };
88
+ readonly workspace: (identity: {
89
+ saasApp: string;
90
+ workspaceId: string;
91
+ }) => {
92
+ readonly channels: {
93
+ readonly list: () => Promise<WorkspaceChannel[]>;
94
+ readonly create: (input: {
95
+ name: string;
96
+ token_name: string;
97
+ site_url?: string;
98
+ }) => Promise<ChannelMint<WorkspaceChannel>>;
99
+ readonly update: (channelId: string, input: {
100
+ status: "active" | "disabled";
101
+ site_url?: string;
102
+ }) => Promise<WorkspaceChannel>;
103
+ readonly tokens: {
104
+ readonly mint: (channelId: string, input: {
105
+ name: string;
106
+ }) => Promise<ChannelMint<WorkspaceChannel>>;
107
+ readonly revoke: (channelId: string, tokenId: string) => Promise<void>;
108
+ };
109
+ readonly webhooks: {
110
+ readonly list: (channelId: string) => Promise<WebhookPage>;
111
+ readonly create: (channelId: string, input: WebhookWrite) => Promise<WebhookSecret>;
112
+ readonly update: (channelId: string, webhookId: string, input: WebhookWrite) => Promise<ChannelWebhook>;
113
+ readonly delete: (channelId: string, webhookId: string) => Promise<void>;
114
+ readonly revealSecret: (channelId: string, webhookId: string) => Promise<WebhookSecret>;
115
+ readonly deliveries: (channelId: string, webhookId: string, cursor?: string) => Promise<{
116
+ items: ChannelWebhookDelivery[];
117
+ next_cursor?: string;
118
+ has_more: boolean;
119
+ }>;
120
+ readonly replay: (channelId: string, webhookId: string, deliveryId: string) => Promise<void>;
121
+ readonly test: (channelId: string, webhookId: string) => Promise<void>;
122
+ };
123
+ };
124
+ };
125
+ };
package/dist/index.js ADDED
@@ -0,0 +1,157 @@
1
+ const RETRYABLE = new Set([429, 502, 503, 504]);
2
+ export class ControlAPIError extends Error {
3
+ status;
4
+ code;
5
+ requestId;
6
+ constructor(message, status, code, requestId) {
7
+ super(message);
8
+ this.status = status;
9
+ this.code = code;
10
+ this.requestId = requestId;
11
+ this.name = "ControlAPIError";
12
+ }
13
+ }
14
+ function required(value, label) {
15
+ const normalized = value.trim();
16
+ if (!normalized)
17
+ throw new Error(`${label} is required`);
18
+ return normalized;
19
+ }
20
+ function segment(value) {
21
+ return encodeURIComponent(required(value, "path value"));
22
+ }
23
+ function query(path, values) {
24
+ const params = new URLSearchParams();
25
+ for (const [key, value] of Object.entries(values)) {
26
+ if (value !== undefined && String(value).trim())
27
+ params.set(key, String(value).trim());
28
+ }
29
+ return params.size ? `${path}?${params}` : path;
30
+ }
31
+ export function createControlClient(options) {
32
+ const baseUrl = required(options.baseUrl, "baseUrl").replace(/\/+$/, "");
33
+ const fetcher = options.fetch ?? globalThis.fetch;
34
+ if (!fetcher)
35
+ throw new Error("fetch implementation is required");
36
+ const timeoutMs = options.timeoutMs ?? 10_000;
37
+ const retryAttempts = options.retryAttempts ?? 1;
38
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
39
+ throw new Error("timeoutMs must be positive");
40
+ if (!Number.isSafeInteger(retryAttempts) || retryAttempts < 0)
41
+ throw new Error("retryAttempts must be a non-negative integer");
42
+ async function request(operation, path, init = {}) {
43
+ const method = (init.method ?? "GET").toUpperCase();
44
+ const started = Date.now();
45
+ let attempts = 0;
46
+ let status = 0;
47
+ let requestId = "";
48
+ let outcome = "network_error";
49
+ try {
50
+ const token = required((await options.getAccessToken()) ?? "", "access token");
51
+ const headers = new Headers(await options.getHeaders?.());
52
+ new Headers(init.headers).forEach((value, key) => headers.set(key, value));
53
+ headers.set("Authorization", `Bearer ${token}`);
54
+ if (init.body !== undefined && !headers.has("Content-Type"))
55
+ headers.set("Content-Type", "application/json");
56
+ let response;
57
+ for (let attempt = 0; attempt <= (method === "GET" ? retryAttempts : 0); attempt += 1) {
58
+ attempts = attempt + 1;
59
+ const controller = new AbortController();
60
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
61
+ try {
62
+ response = await fetcher(`${baseUrl}${path}`, { ...init, method, headers, cache: "no-store", signal: controller.signal });
63
+ }
64
+ catch {
65
+ if (controller.signal.aborted) {
66
+ outcome = "timeout";
67
+ throw new ControlAPIError("Control API request timed out", 0, "control_request_timeout", "");
68
+ }
69
+ outcome = "network_error";
70
+ throw new ControlAPIError("Control API request failed", 0, "control_network_error", "");
71
+ }
72
+ finally {
73
+ clearTimeout(timeout);
74
+ }
75
+ if (!RETRYABLE.has(response.status) || attempt === retryAttempts || method !== "GET")
76
+ break;
77
+ }
78
+ if (!response)
79
+ throw new ControlAPIError("Control API request failed", 0, "control_network_error", "");
80
+ status = response.status;
81
+ requestId = response.headers.get("X-Request-ID") ?? "";
82
+ const text = await response.text();
83
+ let payload;
84
+ try {
85
+ payload = text ? JSON.parse(text) : undefined;
86
+ }
87
+ catch {
88
+ outcome = "invalid_response";
89
+ throw new ControlAPIError("Control API response was not valid JSON", status, "invalid_control_response", requestId);
90
+ }
91
+ if (!response.ok) {
92
+ const error = payload;
93
+ requestId = typeof error?.request_id === "string" ? error.request_id : requestId;
94
+ outcome = "api_error";
95
+ throw new ControlAPIError(typeof error?.message === "string" ? error.message : "Control API request failed", status, typeof error?.code === "string" ? error.code : "control_request_failed", requestId);
96
+ }
97
+ outcome = "success";
98
+ return payload;
99
+ }
100
+ finally {
101
+ try {
102
+ options.observe?.({ operation, method, outcome, status, requestId, durationMs: Math.max(0, Date.now() - started), attempts });
103
+ }
104
+ catch { }
105
+ }
106
+ }
107
+ const platformBase = "/api/v1/platform/channels";
108
+ const platformChannel = (id) => `${platformBase}/${segment(id)}`;
109
+ const platformWebhook = (channelId, webhookId) => `${platformChannel(channelId)}/webhooks/${segment(webhookId)}`;
110
+ const platform = {
111
+ channels: {
112
+ list: (filters = {}) => request("platform.channels.list", query(platformBase, filters)),
113
+ get: (id) => request("platform.channels.get", platformChannel(id)),
114
+ create: (input) => request("platform.channels.create", platformBase, { method: "POST", body: JSON.stringify(input) }),
115
+ update: (id, input) => request("platform.channels.update", platformChannel(id), { method: "PATCH", body: JSON.stringify(input) }),
116
+ tokens: {
117
+ mint: (channelId, input) => request("platform.channels.tokens.mint", `${platformChannel(channelId)}/tokens`, { method: "POST", body: JSON.stringify(input) }),
118
+ revoke: (channelId, tokenId, input) => request("platform.channels.tokens.revoke", `${platformChannel(channelId)}/tokens/${segment(tokenId)}/revoke`, { method: "POST", body: JSON.stringify(input) }),
119
+ },
120
+ webhooks: {
121
+ fleet: (filters = {}) => request("platform.channels.webhooks.fleet", query(`${platformBase}/webhooks`, filters)),
122
+ list: (channelId) => request("platform.channels.webhooks.list", `${platformChannel(channelId)}/webhooks`),
123
+ create: (channelId, input) => request("platform.channels.webhooks.create", `${platformChannel(channelId)}/webhooks`, { method: "POST", body: JSON.stringify(input) }),
124
+ update: (channelId, webhookId, input) => request("platform.channels.webhooks.update", platformWebhook(channelId, webhookId), { method: "PATCH", body: JSON.stringify(input) }),
125
+ delete: (channelId, webhookId, input) => request("platform.channels.webhooks.delete", platformWebhook(channelId, webhookId), { method: "DELETE", body: JSON.stringify(input) }),
126
+ revealSecret: (channelId, webhookId, input) => request("platform.channels.webhooks.secret.reveal", `${platformWebhook(channelId, webhookId)}/secret/reveal`, { method: "POST", body: JSON.stringify(input) }),
127
+ deliveries: (channelId, webhookId, cursor) => request("platform.channels.webhooks.deliveries", query(`${platformWebhook(channelId, webhookId)}/deliveries`, { cursor })),
128
+ replay: (channelId, webhookId, deliveryId, input) => request("platform.channels.webhooks.replay", `${platformWebhook(channelId, webhookId)}/deliveries/${segment(deliveryId)}/replay`, { method: "POST", body: JSON.stringify(input) }),
129
+ },
130
+ },
131
+ };
132
+ function workspace(identity) {
133
+ const base = `/api/v1/platform/saas/${segment(identity.saasApp)}/workspaces/${segment(identity.workspaceId)}/channels`;
134
+ const channel = (id) => `${base}/${segment(id)}`;
135
+ const webhook = (channelId, webhookId) => `${channel(channelId)}/webhooks/${segment(webhookId)}`;
136
+ return { channels: {
137
+ list: async () => (await request("workspace.channels.list", base)).items ?? [],
138
+ create: (input) => request("workspace.channels.create", base, { method: "POST", body: JSON.stringify(input) }),
139
+ update: (channelId, input) => request("workspace.channels.update", channel(channelId), { method: "PATCH", body: JSON.stringify(input) }),
140
+ tokens: {
141
+ mint: (channelId, input) => request("workspace.channels.tokens.mint", `${channel(channelId)}/tokens`, { method: "POST", body: JSON.stringify(input) }),
142
+ revoke: (channelId, tokenId) => request("workspace.channels.tokens.revoke", `${channel(channelId)}/tokens/${segment(tokenId)}`, { method: "DELETE" }),
143
+ },
144
+ webhooks: {
145
+ list: (channelId) => request("workspace.channels.webhooks.list", `${channel(channelId)}/webhooks`),
146
+ create: (channelId, input) => request("workspace.channels.webhooks.create", `${channel(channelId)}/webhooks`, { method: "POST", body: JSON.stringify(input) }),
147
+ update: (channelId, webhookId, input) => request("workspace.channels.webhooks.update", webhook(channelId, webhookId), { method: "PATCH", body: JSON.stringify(input) }),
148
+ delete: (channelId, webhookId) => request("workspace.channels.webhooks.delete", webhook(channelId, webhookId), { method: "DELETE" }),
149
+ revealSecret: (channelId, webhookId) => request("workspace.channels.webhooks.secret.reveal", `${webhook(channelId, webhookId)}/secret`),
150
+ deliveries: (channelId, webhookId, cursor) => request("workspace.channels.webhooks.deliveries", query(`${webhook(channelId, webhookId)}/deliveries`, { limit: 20, cursor })),
151
+ replay: (channelId, webhookId, deliveryId) => request("workspace.channels.webhooks.replay", `${webhook(channelId, webhookId)}/deliveries/${segment(deliveryId)}/replay`, { method: "POST" }),
152
+ test: (channelId, webhookId) => request("workspace.channels.webhooks.test", `${webhook(channelId, webhookId)}/test`, { method: "POST" }),
153
+ },
154
+ } };
155
+ }
156
+ return { platform, workspace };
157
+ }
package/dist/next.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import "server-only";
2
+ export * from "./index.js";
package/dist/next.js ADDED
@@ -0,0 +1,2 @@
1
+ import "server-only";
2
+ export * from "./index.js";
@@ -0,0 +1,125 @@
1
+ export type ChannelStatus = "active" | "disabled";
2
+ export type ChannelWebhookStatus = "active" | "disabled";
3
+ export type ChannelWebhookHealth = "healthy" | "degraded" | "dead" | "idle" | "disabled";
4
+ export interface ChannelToken {
5
+ id: string;
6
+ name: string;
7
+ token_hint: string;
8
+ is_active: boolean;
9
+ created_at: string;
10
+ last_used_at?: string;
11
+ revoked_at?: string;
12
+ }
13
+ export interface WorkspaceChannel {
14
+ id: string;
15
+ kind: string;
16
+ name: string;
17
+ site_url: string;
18
+ status: string;
19
+ tokens: ChannelToken[];
20
+ created_at: string;
21
+ }
22
+ export interface PlatformChannel {
23
+ id: string;
24
+ kind: string;
25
+ name: string;
26
+ saas_app: string;
27
+ workspace_id: string;
28
+ workspace_name: string;
29
+ workspace_slug: string;
30
+ status: string;
31
+ active_tokens: number;
32
+ revoked_tokens: number;
33
+ last_used_at?: string;
34
+ created_at: string;
35
+ updated_at: string;
36
+ }
37
+ export interface ChannelMint<TChannel> {
38
+ channel: TChannel;
39
+ token_id: string;
40
+ raw_token: string;
41
+ }
42
+ export interface ChannelAudit {
43
+ id: string;
44
+ channel_id: string;
45
+ token_id?: string;
46
+ webhook_id?: string;
47
+ saas_app: string;
48
+ workspace_id: string;
49
+ operator_id: string;
50
+ authority_level: string;
51
+ action: string;
52
+ outcome: string;
53
+ reason: string;
54
+ request_id?: string;
55
+ before_state?: unknown;
56
+ after_state?: unknown;
57
+ created_at: string;
58
+ }
59
+ export interface ChannelWebhook {
60
+ id: string;
61
+ channel_id: string;
62
+ channel_name?: string;
63
+ saas_app?: string;
64
+ workspace_id?: string;
65
+ workspace_name?: string;
66
+ workspace_slug?: string;
67
+ url: string;
68
+ event_types: string[];
69
+ status: ChannelWebhookStatus;
70
+ health?: ChannelWebhookHealth;
71
+ pending_count: number;
72
+ dead_count: number;
73
+ last_error?: string;
74
+ last_delivered_at?: string;
75
+ last_failed_at?: string;
76
+ oldest_pending_at?: string;
77
+ created_at: string;
78
+ updated_at: string;
79
+ }
80
+ export interface ChannelWebhookDelivery {
81
+ id: string;
82
+ webhook_id?: string;
83
+ event_id: string;
84
+ event_type: string;
85
+ status: string;
86
+ attempt_count: number;
87
+ next_attempt_at: string;
88
+ last_error?: string;
89
+ delivered_at?: string;
90
+ dead_at?: string;
91
+ created_at: string;
92
+ }
93
+ export interface CursorPage<T> {
94
+ items: T[];
95
+ next_cursor?: string;
96
+ has_more: boolean;
97
+ }
98
+ export interface WebhookPage extends CursorPage<ChannelWebhook> {
99
+ event_types: string[];
100
+ }
101
+ export interface WebhookSecret {
102
+ webhook: ChannelWebhook;
103
+ secret: string;
104
+ }
105
+ export interface PlatformChannelDetail {
106
+ channel: PlatformChannel;
107
+ tokens: ChannelToken[];
108
+ audit: ChannelAudit[];
109
+ }
110
+ export interface ChannelFilters {
111
+ q?: string;
112
+ saas_app?: string;
113
+ status?: string;
114
+ token_state?: string;
115
+ usage?: string;
116
+ health?: string;
117
+ cursor?: string;
118
+ }
119
+ export interface WebhookWrite {
120
+ url?: string;
121
+ event_types?: string[];
122
+ status?: ChannelWebhookStatus;
123
+ reason?: string;
124
+ confirm?: boolean;
125
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@c1m-public/control-client",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "Typed server-side client for C1M platform and workspace control APIs",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/cloud1mediaLtd/mml-js.git",
10
+ "directory": "packages/control-client"
11
+ },
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
16
+ "./next": { "types": "./dist/next.d.ts", "default": "./dist/next.js" }
17
+ },
18
+ "files": ["dist", "README.md"],
19
+ "engines": { "node": ">=20" },
20
+ "publishConfig": { "access": "public" },
21
+ "scripts": {
22
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
23
+ "build": "pnpm clean && tsc -p tsconfig.build.json",
24
+ "lint": "tsc -p tsconfig.json --noEmit",
25
+ "test": "vitest",
26
+ "test:run": "vitest run",
27
+ "type-check": "tsc -p tsconfig.json --noEmit"
28
+ },
29
+ "dependencies": { "server-only": "0.0.1" },
30
+ "devDependencies": { "typescript": "5.9.3", "vitest": "^4.1.4" }
31
+ }