@stackshift-cloud/sdk 1.3.0 → 1.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.
- package/README.md +50 -0
- package/dist/asset-workflows.d.ts +5 -13
- package/dist/assets-platform-types.d.ts +19 -2
- package/dist/assets.d.ts +2 -1
- package/dist/assets.js +3 -0
- package/dist/client.js +2 -1
- package/dist/generated/assets-contract.d.ts +41 -6
- package/dist/generated/assets-contract.js +8 -1
- package/dist/index.d.ts +13 -2
- package/dist/index.js +8 -0
- package/dist/mail-advanced-types.d.ts +11 -0
- package/dist/mail-billing.d.ts +4 -0
- package/dist/mail-campaigns.d.ts +1 -1
- package/dist/mail-mailboxes.d.ts +358 -0
- package/dist/mail-mailboxes.js +202 -0
- package/dist/mail-marketing.d.ts +9 -2
- package/dist/mail.d.ts +8 -0
- package/dist/mail.js +14 -0
- package/dist/s2-upload.d.ts +22 -0
- package/dist/s2-upload.js +49 -0
- package/dist/s2.d.ts +34 -0
- package/dist/s2.js +31 -0
- package/dist/support-action-manifest.d.ts +3 -0
- package/dist/support-action-manifest.js +40 -0
- package/dist/support-action-receiver.d.ts +20 -0
- package/dist/support-action-receiver.js +37 -0
- package/dist/support-action-sqlite.d.ts +17 -0
- package/dist/support-action-sqlite.js +30 -0
- package/dist/support-action-types.d.ts +93 -0
- package/dist/support-action-types.js +1 -0
- package/dist/support-actions.d.ts +19 -0
- package/dist/support-actions.js +213 -0
- package/dist/types.d.ts +17 -0
- package/dist/upload-controller.d.ts +2 -0
- package/dist/upload-controller.js +41 -18
- package/package.json +3 -2
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ChunkedUploadController, putBlob } from './upload-controller.js';
|
|
2
|
+
/** Bind these callbacks to authenticated application-server routes. Never put permanent S3 credentials in a browser. */
|
|
3
|
+
export class S2Uploader {
|
|
4
|
+
controller;
|
|
5
|
+
constructor(backend, store) {
|
|
6
|
+
this.controller = new ChunkedUploadController({
|
|
7
|
+
create: (file, request) => backend.create({ ...request, size: file.size }),
|
|
8
|
+
resume: (context) => backend.resume(context),
|
|
9
|
+
uploadPart: async (session, partNumber, chunk, checksumSHA256, callbacks) => {
|
|
10
|
+
const signed = await backend.signPart(session, { partNumber, size: chunk.size, checksumSHA256 });
|
|
11
|
+
await putBlob(signed.url, chunk, callbacks, signed.headers);
|
|
12
|
+
},
|
|
13
|
+
complete: (session) => backend.complete(session),
|
|
14
|
+
cancel: (session) => backend.cancel(session),
|
|
15
|
+
}, store);
|
|
16
|
+
}
|
|
17
|
+
upload(file, request, options = {}) {
|
|
18
|
+
if (file.size === 0)
|
|
19
|
+
return Promise.reject(new Error('Use a single presigned PUT for an empty object'));
|
|
20
|
+
return this.controller.upload(file, request, { ...options, requireMatchingFile: true });
|
|
21
|
+
}
|
|
22
|
+
cancel(resumeKey) { return this.controller.cancel(resumeKey); }
|
|
23
|
+
}
|
|
24
|
+
/** Use an account-specific namespace, and clear it on logout. Stored sessions contain temporary upload capabilities. */
|
|
25
|
+
export function createS2ResumeStore(storage, namespace) {
|
|
26
|
+
if (!namespace.trim())
|
|
27
|
+
throw new Error('An account-specific upload namespace is required');
|
|
28
|
+
const key = (value) => `stackshift:s2:${encodeURIComponent(namespace)}:${encodeURIComponent(value)}`;
|
|
29
|
+
return {
|
|
30
|
+
async get(value) {
|
|
31
|
+
const raw = storage.getItem(key(value));
|
|
32
|
+
if (raw === null)
|
|
33
|
+
return undefined;
|
|
34
|
+
let record;
|
|
35
|
+
try {
|
|
36
|
+
record = JSON.parse(raw);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error('Saved upload state is unreadable; clear it before starting again');
|
|
40
|
+
}
|
|
41
|
+
if (!record || record.key !== value || typeof record.fingerprint !== 'string' || !record.session?.id) {
|
|
42
|
+
throw new Error('Saved upload state is invalid; clear it before starting again');
|
|
43
|
+
}
|
|
44
|
+
return record;
|
|
45
|
+
},
|
|
46
|
+
async set(record) { storage.setItem(key(record.key), JSON.stringify(record)); },
|
|
47
|
+
async delete(value) { storage.removeItem(key(value)); },
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/s2.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { StackShift } from './client.js';
|
|
2
|
+
import type { S2UploadBackend } from './s2-upload.js';
|
|
3
|
+
import type { ChunkUploadSession, UploadedPart } from './upload-controller.js';
|
|
4
|
+
export interface S2Object {
|
|
5
|
+
id: string;
|
|
6
|
+
version_id: string;
|
|
7
|
+
bucket_id: string;
|
|
8
|
+
key: string;
|
|
9
|
+
size: number;
|
|
10
|
+
etag: string;
|
|
11
|
+
checksum_sha256: string;
|
|
12
|
+
content_type: string;
|
|
13
|
+
}
|
|
14
|
+
/** Server-side adapter. Authorize the application's user and destination before forwarding browser requests. */
|
|
15
|
+
export declare class S2UploadsClient implements S2UploadBackend<S2Object> {
|
|
16
|
+
private readonly client;
|
|
17
|
+
private readonly path;
|
|
18
|
+
constructor(client: StackShift, bucketID: string);
|
|
19
|
+
create(input: {
|
|
20
|
+
key: string;
|
|
21
|
+
size: number;
|
|
22
|
+
mimeType: string;
|
|
23
|
+
}): Promise<ChunkUploadSession>;
|
|
24
|
+
resume(context: string): Promise<ChunkUploadSession>;
|
|
25
|
+
signPart(session: ChunkUploadSession, part: UploadedPart): Promise<{
|
|
26
|
+
url: string;
|
|
27
|
+
headers: {
|
|
28
|
+
[k: string]: string;
|
|
29
|
+
};
|
|
30
|
+
}>;
|
|
31
|
+
complete(session: ChunkUploadSession): Promise<S2Object>;
|
|
32
|
+
cancel(session: ChunkUploadSession): Promise<void>;
|
|
33
|
+
private action;
|
|
34
|
+
}
|
package/dist/s2.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Server-side adapter. Authorize the application's user and destination before forwarding browser requests. */
|
|
2
|
+
export class S2UploadsClient {
|
|
3
|
+
client;
|
|
4
|
+
path;
|
|
5
|
+
constructor(client, bucketID) {
|
|
6
|
+
this.client = client;
|
|
7
|
+
if (!bucketID)
|
|
8
|
+
throw new Error('A bucket ID is required');
|
|
9
|
+
this.path = `/buckets/${encodeURIComponent(bucketID)}/uploads`;
|
|
10
|
+
}
|
|
11
|
+
create(input) {
|
|
12
|
+
return this.client.request(this.path, { method: 'POST', body: input });
|
|
13
|
+
}
|
|
14
|
+
resume(context) {
|
|
15
|
+
return this.action('resume', context);
|
|
16
|
+
}
|
|
17
|
+
async signPart(session, part) {
|
|
18
|
+
const result = await this.action('sign-part', session.uploadToken, part);
|
|
19
|
+
const headers = Object.fromEntries(Object.entries(result.headers ?? {}).map(([name, values]) => [name, values.join(', ')]));
|
|
20
|
+
return { url: result.url, headers };
|
|
21
|
+
}
|
|
22
|
+
complete(session) {
|
|
23
|
+
return this.action('complete', session.uploadToken);
|
|
24
|
+
}
|
|
25
|
+
async cancel(session) {
|
|
26
|
+
await this.action('cancel', session.uploadToken);
|
|
27
|
+
}
|
|
28
|
+
action(action, token, part) {
|
|
29
|
+
return this.client.request(`${this.path}/actions`, { method: 'POST', body: { action, token, part } });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { SupportActionDescriptor } from './support-action-types.js';
|
|
2
|
+
/** Validate action declarations locally; server registration and destination checks remain authoritative. */
|
|
3
|
+
export declare function validateSupportActionCatalog(version: string, actions: SupportActionDescriptor[]): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { actionCheck } from './support-actions.js';
|
|
2
|
+
const reserved = new Set(['customer', 'customer_id', 'external_customer_id', 'external_id', 'contact_id', 'workspace_id', 'environment_id', 'inbox_id', 'conversation_id', 'installation_id', 'tenant_id', 'user_id', 'organization_id', 'authorization', 'scope', 'identity', 'credentials', 'secret', 'token']);
|
|
3
|
+
function endpoint(value) {
|
|
4
|
+
if (!value || value.length > 2048)
|
|
5
|
+
return false;
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(value);
|
|
8
|
+
return url.protocol === 'https:' && !!url.hostname && !url.username && !url.password && !url.search && !url.hash && !value.includes('?') && !value.includes('#');
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function fields(schema, input) {
|
|
15
|
+
actionCheck(schema && typeof schema === 'object' && !Array.isArray(schema) && Object.keys(schema).length <= 32, 'invalid_catalog');
|
|
16
|
+
for (const [name, field] of Object.entries(schema)) {
|
|
17
|
+
actionCheck(/^[a-z][a-z0-9_]{0,63}$/.test(name) && (!input || (!reserved.has(name) && !/^(customer_|tenant_|auth_)/.test(name))), 'invalid_catalog');
|
|
18
|
+
actionCheck(field && typeof field.required === 'boolean' && Object.keys(field).every(k => ['type', 'required', 'max_length', 'minimum', 'maximum', 'enum'].includes(k)), 'invalid_catalog');
|
|
19
|
+
if (field.type === 'string')
|
|
20
|
+
actionCheck(Number.isInteger(field.max_length) && field.max_length >= 1 && field.max_length <= 16000 && field.minimum === undefined && field.maximum === undefined && (!field.enum || (Array.isArray(field.enum) && field.enum.length <= 64 && new Set(field.enum).size === field.enum.length && field.enum.every(v => typeof v === 'string' && [...v].length <= field.max_length))), 'invalid_catalog');
|
|
21
|
+
else if (field.type === 'number' || field.type === 'integer')
|
|
22
|
+
actionCheck(!field.max_length && !field.enum?.length && [field.minimum, field.maximum].every(n => n === undefined || (Number.isFinite(n) && (field.type !== 'integer' || Number.isSafeInteger(n)))) && (field.minimum === undefined || field.maximum === undefined || field.minimum <= field.maximum), 'invalid_catalog');
|
|
23
|
+
else
|
|
24
|
+
actionCheck(field.type === 'boolean' && !field.max_length && !field.enum?.length && field.minimum === undefined && field.maximum === undefined, 'invalid_catalog');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Validate action declarations locally; server registration and destination checks remain authoritative. */
|
|
28
|
+
export function validateSupportActionCatalog(version, actions) {
|
|
29
|
+
actionCheck(typeof version === 'string' && version.length <= 32 && /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(version) && Array.isArray(actions) && actions.length >= 1 && actions.length <= 32, 'invalid_catalog');
|
|
30
|
+
const names = new Set();
|
|
31
|
+
for (const action of actions) {
|
|
32
|
+
actionCheck(/^[a-z][a-z0-9_.]{0,79}$/.test(action.name) && !names.has(action.name) && action.version === version && action.identity_required === true && ['read', 'bounded_write', 'customer_change', 'financial', 'security'].includes(action.risk) && typeof action.idempotency_supported === 'boolean' && typeof action.safe_retry === 'boolean' && Number.isInteger(action.timeout_seconds) && action.timeout_seconds >= 1 && action.timeout_seconds <= 30 && endpoint(action.endpoint), 'invalid_catalog');
|
|
33
|
+
actionCheck((action.risk === 'read' || (action.idempotency_supported && endpoint(action.reconciliation_endpoint) && !!action.verification_operation)) && (!action.safe_retry || action.idempotency_supported) && (!action.reconciliation_endpoint || endpoint(action.reconciliation_endpoint)), 'invalid_catalog');
|
|
34
|
+
fields(action.input, true);
|
|
35
|
+
fields(action.output, false);
|
|
36
|
+
names.add(action.name);
|
|
37
|
+
}
|
|
38
|
+
for (const action of actions)
|
|
39
|
+
actionCheck(!action.verification_operation || actions.some(a => a.name === action.verification_operation && a.risk === 'read'), 'invalid_catalog');
|
|
40
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SupportActionReceipt, SupportActionRequest, SupportActionVerification } from './support-action-types.js';
|
|
2
|
+
export interface SupportActionClaim {
|
|
3
|
+
state: 'acquired' | 'completed' | 'running' | 'unknown' | 'conflict';
|
|
4
|
+
fence?: string;
|
|
5
|
+
response?: Uint8Array;
|
|
6
|
+
}
|
|
7
|
+
export interface SupportActionDurableStore {
|
|
8
|
+
claim(namespace: 'delivery' | 'execution', key: string, digest: string): Promise<SupportActionClaim>;
|
|
9
|
+
complete(namespace: 'delivery' | 'execution', key: string, fence: string, response: Uint8Array, unknown?: boolean): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export type SupportActionHandler = (request: SupportActionRequest) => Promise<Pick<SupportActionReceipt, 'state' | 'result' | 'error_code'>>;
|
|
12
|
+
export declare function receiveSupportAction(raw: Uint8Array, headers: Record<string, string>, options: {
|
|
13
|
+
verification: SupportActionVerification;
|
|
14
|
+
store: SupportActionDurableStore;
|
|
15
|
+
execute: SupportActionHandler;
|
|
16
|
+
reconcile: SupportActionHandler;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
body: Uint8Array<ArrayBufferLike>;
|
|
19
|
+
headers: Record<string, string>;
|
|
20
|
+
}>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ACTION_RECEIPT_CONTEXT, actionCheck, actionHeaders, encodeSupportActionReceipt, signSupportAction, validateSupportActionFields, verifySupportAction } from './support-actions.js';
|
|
2
|
+
export async function receiveSupportAction(raw, headers, options) {
|
|
3
|
+
const verification = { ...options.verification, now: options.verification.now ?? Math.floor(Date.now() / 1000) };
|
|
4
|
+
const request = await verifySupportAction(raw, headers, verification);
|
|
5
|
+
const delivery = actionHeaders(headers)['x-stackshift-delivery-id'];
|
|
6
|
+
const digest = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(raw))), b => b.toString(16).padStart(2, '0')).join('');
|
|
7
|
+
const deliveryKey = `${request.installation_id}:${delivery}`;
|
|
8
|
+
const deliveryClaim = await options.store.claim('delivery', deliveryKey, digest);
|
|
9
|
+
actionCheck(deliveryClaim.state === 'acquired' && deliveryClaim.fence, 'delivery_replay');
|
|
10
|
+
const key = [request.installation_id, request.action.name, request.action.version, request.request_id, request.mode].join(':');
|
|
11
|
+
const claim = await options.store.claim('execution', key, digest);
|
|
12
|
+
actionCheck(claim.state !== 'conflict', 'idempotency_conflict');
|
|
13
|
+
let response;
|
|
14
|
+
if ((claim.state === 'completed' || claim.state === 'unknown') && claim.response)
|
|
15
|
+
response = claim.response;
|
|
16
|
+
else if (claim.state === 'running' || claim.state === 'unknown')
|
|
17
|
+
response = encodeSupportActionReceipt(request.request_id, claim.state === 'unknown' ? 'unknown' : 'accepted', {}, claim.state === 'unknown' ? 'execution_unknown' : 'execution_in_progress');
|
|
18
|
+
else {
|
|
19
|
+
actionCheck(claim.state === 'acquired' && claim.fence, 'invalid_store_claim');
|
|
20
|
+
let state = 'unknown';
|
|
21
|
+
try {
|
|
22
|
+
const result = await (request.mode === 'reconcile' ? options.reconcile : options.execute)(request);
|
|
23
|
+
state = result.state;
|
|
24
|
+
const action = verification.actions.find(a => a.name === request.action.name && a.version === request.action.version);
|
|
25
|
+
if (state === 'succeeded')
|
|
26
|
+
validateSupportActionFields(result.result, action.output);
|
|
27
|
+
response = encodeSupportActionReceipt(request.request_id, state, result.result, result.error_code);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
state = 'unknown';
|
|
31
|
+
response = encodeSupportActionReceipt(request.request_id, state, {}, 'handler_outcome_unknown');
|
|
32
|
+
}
|
|
33
|
+
await options.store.complete('execution', key, claim.fence, response, state === 'unknown');
|
|
34
|
+
}
|
|
35
|
+
await options.store.complete('delivery', deliveryKey, deliveryClaim.fence, response);
|
|
36
|
+
return { body: response, headers: await signSupportAction(response, ACTION_RECEIPT_CONTEXT, verification.keyID, verification.secret, verification.now, delivery) };
|
|
37
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SupportActionClaim, SupportActionDurableStore } from './support-action-receiver.js';
|
|
2
|
+
/** Compatible with node:sqlite DatabaseSync; supply a persistent disk database. */
|
|
3
|
+
export interface SupportActionSQLiteDatabase {
|
|
4
|
+
exec(sql: string): void;
|
|
5
|
+
prepare(sql: string): {
|
|
6
|
+
run(...values: any[]): {
|
|
7
|
+
changes: number | bigint;
|
|
8
|
+
};
|
|
9
|
+
get(...values: any[]): unknown;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export declare class SupportActionSQLiteStore implements SupportActionDurableStore {
|
|
13
|
+
private readonly database;
|
|
14
|
+
constructor(database: SupportActionSQLiteDatabase);
|
|
15
|
+
claim(namespace: 'delivery' | 'execution', key: string, digest: string): Promise<SupportActionClaim>;
|
|
16
|
+
complete(namespace: 'delivery' | 'execution', key: string, fence: string, response: Uint8Array, unknown?: boolean): Promise<void>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { actionCheck } from './support-actions.js';
|
|
2
|
+
export class SupportActionSQLiteStore {
|
|
3
|
+
database;
|
|
4
|
+
constructor(database) {
|
|
5
|
+
this.database = database;
|
|
6
|
+
database.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=10000;');
|
|
7
|
+
database.exec(`CREATE TABLE IF NOT EXISTS support_action_claims (
|
|
8
|
+
namespace TEXT NOT NULL, claim_key TEXT NOT NULL, digest TEXT NOT NULL,
|
|
9
|
+
state TEXT NOT NULL, fence TEXT NOT NULL, response BLOB,
|
|
10
|
+
PRIMARY KEY(namespace, claim_key))`);
|
|
11
|
+
}
|
|
12
|
+
async claim(namespace, key, digest) {
|
|
13
|
+
const db = this.database, fence = crypto.randomUUID();
|
|
14
|
+
db.exec('BEGIN IMMEDIATE');
|
|
15
|
+
try {
|
|
16
|
+
const inserted = db.prepare('INSERT OR IGNORE INTO support_action_claims VALUES (?, ?, ?, ?, ?, NULL)').run(namespace, key, digest, 'running', fence);
|
|
17
|
+
const row = db.prepare('SELECT digest,state,fence,response FROM support_action_claims WHERE namespace=? AND claim_key=?').get(namespace, key);
|
|
18
|
+
db.exec('COMMIT');
|
|
19
|
+
return { state: inserted.changes ? 'acquired' : row.digest === digest ? row.state : 'conflict', fence: row.fence, ...(row.response ? { response: new Uint8Array(row.response) } : {}) };
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
db.exec('ROLLBACK');
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async complete(namespace, key, fence, response, unknown = false) {
|
|
27
|
+
const result = this.database.prepare('UPDATE support_action_claims SET state=?,response=? WHERE namespace=? AND claim_key=? AND fence=? AND state=?').run(unknown ? 'unknown' : 'completed', response, namespace, key, fence, 'running');
|
|
28
|
+
actionCheck(Number(result.changes) === 1, 'fence_lost');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface SupportActionField {
|
|
2
|
+
type: 'string' | 'number' | 'integer' | 'boolean';
|
|
3
|
+
required: boolean;
|
|
4
|
+
max_length?: number;
|
|
5
|
+
minimum?: number;
|
|
6
|
+
maximum?: number;
|
|
7
|
+
enum?: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface SupportActionDescriptor {
|
|
10
|
+
name: string;
|
|
11
|
+
version: string;
|
|
12
|
+
endpoint: string;
|
|
13
|
+
reconciliation_endpoint?: string;
|
|
14
|
+
input: Record<string, SupportActionField>;
|
|
15
|
+
output: Record<string, SupportActionField>;
|
|
16
|
+
risk: 'read' | 'bounded_write' | 'customer_change' | 'financial' | 'security';
|
|
17
|
+
identity_required: true;
|
|
18
|
+
idempotency_supported: boolean;
|
|
19
|
+
safe_retry: boolean;
|
|
20
|
+
timeout_seconds: number;
|
|
21
|
+
verification_operation?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface SupportActionManifest {
|
|
24
|
+
schema: 'stackshift.support.app/v2';
|
|
25
|
+
version: string;
|
|
26
|
+
permissions: string[];
|
|
27
|
+
event_types: string[];
|
|
28
|
+
placements: string[];
|
|
29
|
+
webhook_url: string;
|
|
30
|
+
description: string;
|
|
31
|
+
actions: SupportActionDescriptor[];
|
|
32
|
+
}
|
|
33
|
+
export interface SupportActionRequest {
|
|
34
|
+
schema: 'stackshift.support.action/v1';
|
|
35
|
+
request_id: string;
|
|
36
|
+
installation_id: string;
|
|
37
|
+
installation_revision: number;
|
|
38
|
+
app_version_id: string;
|
|
39
|
+
action: {
|
|
40
|
+
name: string;
|
|
41
|
+
version: string;
|
|
42
|
+
};
|
|
43
|
+
scope: {
|
|
44
|
+
workspace_id: string;
|
|
45
|
+
environment_id: string;
|
|
46
|
+
inbox_id: string;
|
|
47
|
+
conversation_id: string;
|
|
48
|
+
contact_id: string;
|
|
49
|
+
};
|
|
50
|
+
customer: {
|
|
51
|
+
external_id: string;
|
|
52
|
+
};
|
|
53
|
+
arguments: Record<string, string | number | boolean>;
|
|
54
|
+
authorization: {
|
|
55
|
+
grant_id?: string;
|
|
56
|
+
revision: number;
|
|
57
|
+
mode: 'automatic' | 'customer' | 'staff';
|
|
58
|
+
};
|
|
59
|
+
issued_at: number;
|
|
60
|
+
expires_at: number;
|
|
61
|
+
mode: 'execute' | 'reconcile';
|
|
62
|
+
target_request_id?: string;
|
|
63
|
+
}
|
|
64
|
+
export interface SupportActionReceipt {
|
|
65
|
+
schema: 'stackshift.support.action-receipt/v1';
|
|
66
|
+
request_id: string;
|
|
67
|
+
state: 'accepted' | 'succeeded' | 'failed' | 'unknown';
|
|
68
|
+
result: Record<string, unknown>;
|
|
69
|
+
error_code?: string;
|
|
70
|
+
}
|
|
71
|
+
export interface SupportActionVerification {
|
|
72
|
+
keyID: string;
|
|
73
|
+
secret: string;
|
|
74
|
+
installationID: string;
|
|
75
|
+
actions: SupportActionDescriptor[];
|
|
76
|
+
expectedCustomerExternalID: string;
|
|
77
|
+
now?: number;
|
|
78
|
+
tolerance?: number;
|
|
79
|
+
}
|
|
80
|
+
export interface SupportActionInstallationInput {
|
|
81
|
+
version_id: string;
|
|
82
|
+
environment_id: string;
|
|
83
|
+
inbox_id: string;
|
|
84
|
+
permissions: string[];
|
|
85
|
+
action_operations: string[];
|
|
86
|
+
confirm_consent: true;
|
|
87
|
+
revision: number;
|
|
88
|
+
}
|
|
89
|
+
export interface SupportActionInstallationCredentials {
|
|
90
|
+
action_key_id: string;
|
|
91
|
+
action_secret: string;
|
|
92
|
+
action_operations: string[];
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SupportActionField, SupportActionReceipt, SupportActionRequest, SupportActionVerification } from './support-action-types.js';
|
|
2
|
+
export declare const ACTION_CONTEXT = "stackshift.support.action/v1";
|
|
3
|
+
export declare const ACTION_RECEIPT_CONTEXT = "stackshift.support.action-receipt/v1";
|
|
4
|
+
export declare class SupportActionError extends Error {
|
|
5
|
+
}
|
|
6
|
+
export declare function actionCheck(ok: unknown, code?: string): asserts ok;
|
|
7
|
+
export declare function signSupportAction(raw: Uint8Array, context: string, keyID: string, secret: string, timestamp: number, deliveryID: string): Promise<Record<string, string>>;
|
|
8
|
+
export declare function actionHeaders(headers: Record<string, string>): Record<string, string>;
|
|
9
|
+
export declare function validateSupportActionFields(value: unknown, fields: Record<string, SupportActionField>): void;
|
|
10
|
+
export declare function verifySupportAction(raw: Uint8Array, headers: Record<string, string>, options: SupportActionVerification): Promise<SupportActionRequest>;
|
|
11
|
+
export declare function encodeSupportActionReceipt(requestID: string, state: SupportActionReceipt['state'], result: Record<string, unknown>, errorCode?: string): Uint8Array<ArrayBuffer>;
|
|
12
|
+
export declare function verifySupportActionReceipt(raw: Uint8Array, headers: Record<string, string>, options: {
|
|
13
|
+
keyID: string;
|
|
14
|
+
secret: string;
|
|
15
|
+
requestID: string;
|
|
16
|
+
deliveryID: string;
|
|
17
|
+
now: number;
|
|
18
|
+
tolerance?: number;
|
|
19
|
+
}): Promise<SupportActionReceipt>;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
export const ACTION_CONTEXT = 'stackshift.support.action/v1';
|
|
2
|
+
export const ACTION_RECEIPT_CONTEXT = 'stackshift.support.action-receipt/v1';
|
|
3
|
+
export class SupportActionError extends Error {
|
|
4
|
+
}
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
7
|
+
export function actionCheck(ok, code = 'invalid_request') { if (!ok)
|
|
8
|
+
throw new SupportActionError(code); }
|
|
9
|
+
function uuid(value) { return typeof value === 'string' && uuidPattern.test(value) && value !== '00000000-0000-0000-0000-000000000000'; }
|
|
10
|
+
function integer(value) { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; }
|
|
11
|
+
function object(value, required, optional = []) {
|
|
12
|
+
actionCheck(value !== null && typeof value === 'object' && !Array.isArray(value));
|
|
13
|
+
const keys = Object.keys(value);
|
|
14
|
+
actionCheck(required.every(k => keys.includes(k)) && keys.every(k => required.includes(k) || optional.includes(k)));
|
|
15
|
+
}
|
|
16
|
+
function joined(context, keyID, timestamp, deliveryID, raw) {
|
|
17
|
+
const prefix = encoder.encode(`${context}\n${keyID}\n${timestamp}\n${deliveryID}\n`);
|
|
18
|
+
const result = new Uint8Array(prefix.length + raw.length);
|
|
19
|
+
result.set(prefix);
|
|
20
|
+
result.set(raw, prefix.length);
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
export async function signSupportAction(raw, context, keyID, secret, timestamp, deliveryID) {
|
|
24
|
+
actionCheck([ACTION_CONTEXT, ACTION_RECEIPT_CONTEXT].includes(context) && /^[A-Za-z0-9_-]{1,128}$/.test(keyID) && uuid(deliveryID) && integer(timestamp) && timestamp > 0 && secret.length >= 32);
|
|
25
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
26
|
+
const signature = await crypto.subtle.sign('HMAC', key, joined(context, keyID, String(timestamp), deliveryID, raw));
|
|
27
|
+
return { 'x-stackshift-action-key-id': keyID, 'x-stackshift-timestamp': String(timestamp), 'x-stackshift-delivery-id': deliveryID, 'x-stackshift-action-signature': `v1=${Array.from(new Uint8Array(signature), b => b.toString(16).padStart(2, '0')).join('')}` };
|
|
28
|
+
}
|
|
29
|
+
export function actionHeaders(headers) {
|
|
30
|
+
const result = Object.create(null);
|
|
31
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
32
|
+
const lower = name.toLowerCase();
|
|
33
|
+
actionCheck(!(lower in result), 'invalid_headers');
|
|
34
|
+
result[lower] = value;
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
async function verifySignature(raw, headers, context, keyID, secret, now, tolerance, limit) {
|
|
39
|
+
actionCheck(raw instanceof Uint8Array && raw.length > 0 && raw.length <= limit, 'body_limit');
|
|
40
|
+
actionCheck(integer(now) && Number.isInteger(tolerance) && tolerance > 0 && tolerance <= 300);
|
|
41
|
+
const h = actionHeaders(headers), stamp = h['x-stackshift-timestamp'], delivery = h['x-stackshift-delivery-id'];
|
|
42
|
+
actionCheck(typeof stamp === 'string' && /^[1-9][0-9]{0,15}$/.test(stamp) && integer(Number(stamp)), 'invalid_headers');
|
|
43
|
+
actionCheck(h['x-stackshift-action-key-id'] === keyID && uuid(delivery) && /^[A-Za-z0-9_-]{1,128}$/.test(keyID) && secret.length >= 32, 'binding_mismatch');
|
|
44
|
+
actionCheck(Math.abs(now - Number(stamp)) <= tolerance, 'stale_signature');
|
|
45
|
+
const signature = h['x-stackshift-action-signature'];
|
|
46
|
+
actionCheck(typeof signature === 'string' && /^v1=[0-9a-f]{64}$/.test(signature), 'invalid_signature');
|
|
47
|
+
const bytes = Uint8Array.from(signature.slice(3).match(/../g), v => parseInt(v, 16));
|
|
48
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
49
|
+
actionCheck(await crypto.subtle.verify('HMAC', key, bytes, joined(context, keyID, stamp, delivery, raw)), 'invalid_signature');
|
|
50
|
+
return delivery;
|
|
51
|
+
}
|
|
52
|
+
// Reject duplicate keys before JSON.parse; ordinary JSON.parse silently overwrites them.
|
|
53
|
+
function decode(raw) {
|
|
54
|
+
let text;
|
|
55
|
+
try {
|
|
56
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(raw);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new SupportActionError('invalid_request');
|
|
60
|
+
}
|
|
61
|
+
let i = 0;
|
|
62
|
+
function space() { while (/\s/.test(text[i] ?? '') && i < text.length)
|
|
63
|
+
i++; }
|
|
64
|
+
function string() { const start = i++; while (i < text.length) {
|
|
65
|
+
if (text[i++] === '\\') {
|
|
66
|
+
i++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (text[i - 1] === '"')
|
|
70
|
+
return JSON.parse(text.slice(start, i));
|
|
71
|
+
} throw new SupportActionError('invalid_request'); }
|
|
72
|
+
function value(depth) {
|
|
73
|
+
actionCheck(depth <= 32);
|
|
74
|
+
space();
|
|
75
|
+
if (text[i] === '{') {
|
|
76
|
+
i++;
|
|
77
|
+
space();
|
|
78
|
+
const keys = new Set();
|
|
79
|
+
if (text[i] === '}') {
|
|
80
|
+
i++;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
while (i < text.length) {
|
|
84
|
+
space();
|
|
85
|
+
actionCheck(text[i] === '"');
|
|
86
|
+
const key = string();
|
|
87
|
+
actionCheck(!keys.has(key));
|
|
88
|
+
keys.add(key);
|
|
89
|
+
space();
|
|
90
|
+
actionCheck(text[i++] === ':');
|
|
91
|
+
value(depth + 1);
|
|
92
|
+
space();
|
|
93
|
+
const next = text[i++];
|
|
94
|
+
if (next === '}')
|
|
95
|
+
return;
|
|
96
|
+
actionCheck(next === ',');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (text[i] === '[') {
|
|
100
|
+
i++;
|
|
101
|
+
space();
|
|
102
|
+
if (text[i] === ']') {
|
|
103
|
+
i++;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
;
|
|
107
|
+
while (i < text.length) {
|
|
108
|
+
value(depth + 1);
|
|
109
|
+
space();
|
|
110
|
+
const next = text[i++];
|
|
111
|
+
if (next === ']')
|
|
112
|
+
return;
|
|
113
|
+
actionCheck(next === ',');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else if (text[i] === '"') {
|
|
117
|
+
string();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const start = i;
|
|
122
|
+
while (i < text.length && !/[\s,\]}]/.test(text[i]))
|
|
123
|
+
i++;
|
|
124
|
+
actionCheck(i > start);
|
|
125
|
+
JSON.parse(text.slice(start, i));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
throw new SupportActionError('invalid_request');
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
value(0);
|
|
132
|
+
space();
|
|
133
|
+
actionCheck(i === text.length);
|
|
134
|
+
return JSON.parse(text);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
throw new SupportActionError('invalid_request');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export function validateSupportActionFields(value, fields) {
|
|
141
|
+
actionCheck(value !== null && typeof value === 'object' && !Array.isArray(value));
|
|
142
|
+
const data = value;
|
|
143
|
+
actionCheck(Object.keys(data).every(k => Object.hasOwn(fields, k)));
|
|
144
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
145
|
+
if (!Object.hasOwn(data, name)) {
|
|
146
|
+
actionCheck(!field.required);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const item = data[name];
|
|
150
|
+
actionCheck((field.type === 'string' && typeof item === 'string') || (field.type === 'boolean' && typeof item === 'boolean') || (field.type === 'integer' && typeof item === 'number' && Number.isSafeInteger(item)) || (field.type === 'number' && typeof item === 'number' && Number.isFinite(item)));
|
|
151
|
+
if (typeof item === 'string')
|
|
152
|
+
actionCheck([...item].length <= (field.max_length ?? 16000) && (!field.enum?.length || field.enum.includes(item)));
|
|
153
|
+
if (typeof item === 'number')
|
|
154
|
+
actionCheck(item >= (field.minimum ?? -Infinity) && item <= (field.maximum ?? Infinity));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export async function verifySupportAction(raw, headers, options) {
|
|
158
|
+
const now = options.now ?? Math.floor(Date.now() / 1000), tolerance = options.tolerance ?? 300;
|
|
159
|
+
await verifySignature(raw, headers, ACTION_CONTEXT, options.keyID, options.secret, now, tolerance, 256 * 1024);
|
|
160
|
+
const body = decode(raw);
|
|
161
|
+
object(body, ['schema', 'request_id', 'installation_id', 'installation_revision', 'app_version_id', 'action', 'scope', 'customer', 'arguments', 'authorization', 'issued_at', 'expires_at', 'mode'], ['target_request_id']);
|
|
162
|
+
actionCheck(body.schema === ACTION_CONTEXT && body.installation_id === options.installationID, 'binding_mismatch');
|
|
163
|
+
actionCheck(['request_id', 'installation_id', 'app_version_id'].every(k => uuid(body[k])) && integer(body.installation_revision) && body.installation_revision > 0);
|
|
164
|
+
object(body.action, ['name', 'version']);
|
|
165
|
+
object(body.scope, ['workspace_id', 'environment_id', 'inbox_id', 'conversation_id', 'contact_id']);
|
|
166
|
+
actionCheck(Object.values(body.scope).every(uuid));
|
|
167
|
+
object(body.customer, ['external_id']);
|
|
168
|
+
actionCheck(options.expectedCustomerExternalID.length > 0 && [...options.expectedCustomerExternalID].length <= 256 && body.customer.external_id === options.expectedCustomerExternalID, 'binding_mismatch');
|
|
169
|
+
object(body.authorization, ['revision', 'mode'], ['grant_id']);
|
|
170
|
+
const auth = body.authorization;
|
|
171
|
+
actionCheck(integer(auth.revision) && auth.revision > 0 && ['automatic', 'customer', 'staff'].includes(auth.mode) && (!Object.hasOwn(auth, 'grant_id') || uuid(auth.grant_id)));
|
|
172
|
+
actionCheck(integer(body.issued_at) && integer(body.expires_at) && body.issued_at <= now + tolerance && body.expires_at > now && body.expires_at > body.issued_at, 'request_expired');
|
|
173
|
+
const action = options.actions.find(a => a.name === body.action.name && a.version === body.action.version);
|
|
174
|
+
actionCheck(action, 'action_not_registered');
|
|
175
|
+
if (body.mode === 'reconcile') {
|
|
176
|
+
actionCheck(uuid(body.target_request_id) && body.target_request_id !== body.request_id);
|
|
177
|
+
object(body.arguments, []);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
actionCheck(body.mode === 'execute' && !Object.hasOwn(body, 'target_request_id'));
|
|
181
|
+
validateSupportActionFields(body.arguments, action.input);
|
|
182
|
+
}
|
|
183
|
+
return body;
|
|
184
|
+
}
|
|
185
|
+
function validateReceipt(body) {
|
|
186
|
+
object(body, ['schema', 'request_id', 'state', 'result'], ['error_code']);
|
|
187
|
+
actionCheck(body.schema === ACTION_RECEIPT_CONTEXT && uuid(body.request_id) && ['accepted', 'succeeded', 'failed', 'unknown'].includes(body.state));
|
|
188
|
+
actionCheck(body.result !== null && typeof body.result === 'object' && !Array.isArray(body.result));
|
|
189
|
+
actionCheck(body.state === 'succeeded' || Object.keys(body.result).length === 0);
|
|
190
|
+
actionCheck(!Object.hasOwn(body, 'error_code') || (typeof body.error_code === 'string' && /^[a-z][a-z0-9_]{0,79}$/.test(body.error_code)));
|
|
191
|
+
}
|
|
192
|
+
export function encodeSupportActionReceipt(requestID, state, result, errorCode) {
|
|
193
|
+
const body = { schema: ACTION_RECEIPT_CONTEXT, request_id: requestID, state, result, ...(errorCode !== undefined ? { error_code: errorCode } : {}) };
|
|
194
|
+
validateReceipt(body);
|
|
195
|
+
let serialized;
|
|
196
|
+
try {
|
|
197
|
+
serialized = JSON.stringify(body, (_key, value) => { actionCheck(value !== undefined && typeof value !== 'function' && typeof value !== 'symbol' && (typeof value !== 'number' || Number.isFinite(value))); return value; });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
throw new SupportActionError('invalid_request');
|
|
201
|
+
}
|
|
202
|
+
const raw = encoder.encode(serialized);
|
|
203
|
+
actionCheck(raw.length <= 64 * 1024, 'body_limit');
|
|
204
|
+
decode(raw);
|
|
205
|
+
return raw;
|
|
206
|
+
}
|
|
207
|
+
export async function verifySupportActionReceipt(raw, headers, options) {
|
|
208
|
+
const delivery = await verifySignature(raw, headers, ACTION_RECEIPT_CONTEXT, options.keyID, options.secret, options.now, options.tolerance ?? 300, 64 * 1024);
|
|
209
|
+
const body = decode(raw);
|
|
210
|
+
validateReceipt(body);
|
|
211
|
+
actionCheck(delivery === options.deliveryID && body.request_id === options.requestID, 'binding_mismatch');
|
|
212
|
+
return body;
|
|
213
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -197,6 +197,18 @@ export interface ProjectListOptions {
|
|
|
197
197
|
perPage?: number;
|
|
198
198
|
}
|
|
199
199
|
export interface Asset {
|
|
200
|
+
readiness?: {
|
|
201
|
+
state: "ready" | "processing" | "blocked" | "unknown";
|
|
202
|
+
stages: Array<{
|
|
203
|
+
id: string;
|
|
204
|
+
status: string;
|
|
205
|
+
}>;
|
|
206
|
+
actions: Record<"preview" | "download" | "edit" | "publish", {
|
|
207
|
+
state: "ready" | "blocked" | "unknown";
|
|
208
|
+
code: string;
|
|
209
|
+
message: string;
|
|
210
|
+
}>;
|
|
211
|
+
};
|
|
200
212
|
id: string;
|
|
201
213
|
bucket: string;
|
|
202
214
|
key: string;
|
|
@@ -244,6 +256,11 @@ export interface AssetAIAnnotation {
|
|
|
244
256
|
colors: string[];
|
|
245
257
|
raw?: JsonValue;
|
|
246
258
|
}
|
|
259
|
+
export interface AssetManualDescriptionInput {
|
|
260
|
+
caption?: string;
|
|
261
|
+
alt_text?: string;
|
|
262
|
+
tags?: string[];
|
|
263
|
+
}
|
|
247
264
|
export interface AssetModerationResult {
|
|
248
265
|
id: string;
|
|
249
266
|
provider: string;
|
|
@@ -9,6 +9,7 @@ export interface UploadedPart {
|
|
|
9
9
|
checksumSHA256?: string;
|
|
10
10
|
}
|
|
11
11
|
export interface ChunkUploadSession {
|
|
12
|
+
completionPending?: boolean;
|
|
12
13
|
id: string;
|
|
13
14
|
uploadToken: string;
|
|
14
15
|
expiresAt: string;
|
|
@@ -52,6 +53,7 @@ export interface ChunkedUploadOptions extends ChunkUploadCallbacks {
|
|
|
52
53
|
concurrency?: number;
|
|
53
54
|
retryAttempts?: number;
|
|
54
55
|
cancelOnAbort?: boolean;
|
|
56
|
+
requireMatchingFile?: boolean;
|
|
55
57
|
}
|
|
56
58
|
export declare class UploadResumeInvalidError extends Error {
|
|
57
59
|
constructor(message?: string);
|