@segmentstream-cloud/app 0.7.0 → 0.9.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/dist/app.d.ts +6 -0
- package/dist/app.js +12 -8
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/neon.js +10 -2
- package/dist/oauth.d.ts +44 -0
- package/dist/oauth.js +112 -0
- package/dist/ontology-wire.js +7 -5
- package/dist/ontology.d.ts +2 -0
- package/dist/ontology.js +14 -2
- package/dist/storage.d.ts +54 -0
- package/dist/storage.js +116 -0
- package/package.json +6 -2
package/dist/app.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Environment, SegmentStreamAppConfig } from "./config.js";
|
|
2
2
|
import type { WarehouseClient } from "./data.js";
|
|
3
3
|
import type { SegmentStreamRouteHandlers } from "./handlers.js";
|
|
4
|
+
import type { AgentAuthorizations } from "./oauth.js";
|
|
4
5
|
import type { AuthRouteHandlers, CreateNeonAuth, SignOutOptions } from "./neon.js";
|
|
5
6
|
import type { OntologyClient } from "./ontology.js";
|
|
6
7
|
import type { ProxyOptions, SegmentStreamProxy } from "./proxy.js";
|
|
@@ -46,6 +47,11 @@ export interface SegmentStreamApp<O = unknown> {
|
|
|
46
47
|
readonly warehouse: WarehouseClient;
|
|
47
48
|
/** The app's ontology service, `/api/ontology`, as the signed-in user. Server-only. */
|
|
48
49
|
readonly ontology: OntologyClient<O>;
|
|
50
|
+
/**
|
|
51
|
+
* Agents connecting to the app's MCP server: the request the consent page shows the signed-in
|
|
52
|
+
* member, and the Route Handler that carries out their decision. Server-only.
|
|
53
|
+
*/
|
|
54
|
+
readonly oauth: AgentAuthorizations;
|
|
49
55
|
}
|
|
50
56
|
/** Replaceable capabilities, for tests. */
|
|
51
57
|
export interface AppDependencies {
|
package/dist/app.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createDataClient } from "./data.js";
|
|
|
5
5
|
import { SegmentStreamApiError } from "./errors.js";
|
|
6
6
|
import { createHandlers } from "./handlers.js";
|
|
7
7
|
import { createNeonSession } from "./neon.js";
|
|
8
|
+
import { createAgentAuthorizations, withUnframeableConsent } from "./oauth.js";
|
|
8
9
|
import { createOntologyClient } from "./ontology.js";
|
|
9
10
|
import { createOntologyTransport } from "./ontology-transport.js";
|
|
10
11
|
import { createProxy } from "./proxy.js";
|
|
@@ -35,9 +36,15 @@ export function createApp(config, dependencies = {}) {
|
|
|
35
36
|
getAppCredential,
|
|
36
37
|
});
|
|
37
38
|
const ontologyUrl = environmentValue(environment, "SEGMENTSTREAM_ONTOLOGY_URL");
|
|
39
|
+
// The ontology service accepts SegmentStream app tokens while workspaces move to Neon Auth.
|
|
40
|
+
const ontologyTransport = createOntologyTransport({
|
|
41
|
+
fetch: fetchImplementation,
|
|
42
|
+
ontologyUrl: ontologyUrl === undefined ? undefined : new URL(ontologyUrl).origin,
|
|
43
|
+
credentials: { token: legacy.getAccessToken },
|
|
44
|
+
});
|
|
38
45
|
return {
|
|
39
46
|
authentication: "segmentstream",
|
|
40
|
-
proxy: (options) => createProxy(context, options),
|
|
47
|
+
proxy: (options) => withUnframeableConsent(createProxy(context, options)),
|
|
41
48
|
authHandlers: notConfiguredHandlers,
|
|
42
49
|
handlers: () => createHandlers(context),
|
|
43
50
|
getUser: legacy.getUser,
|
|
@@ -48,12 +55,8 @@ export function createApp(config, dependencies = {}) {
|
|
|
48
55
|
getAccessToken: legacy.getAccessToken,
|
|
49
56
|
fetch: data.fetch,
|
|
50
57
|
warehouse: data.warehouse,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
fetch: fetchImplementation,
|
|
54
|
-
ontologyUrl: ontologyUrl === undefined ? undefined : new URL(ontologyUrl).origin,
|
|
55
|
-
credentials: { token: legacy.getAccessToken },
|
|
56
|
-
}), data.aggregateTransport),
|
|
58
|
+
ontology: createOntologyClient(ontologyTransport, data.aggregateTransport),
|
|
59
|
+
oauth: createAgentAuthorizations(ontologyTransport),
|
|
57
60
|
};
|
|
58
61
|
}
|
|
59
62
|
const neon = createNeonSession({
|
|
@@ -71,7 +74,7 @@ export function createApp(config, dependencies = {}) {
|
|
|
71
74
|
});
|
|
72
75
|
return {
|
|
73
76
|
authentication: "neon-auth",
|
|
74
|
-
proxy: neon.proxy,
|
|
77
|
+
proxy: (options) => withUnframeableConsent(neon.proxy(options)),
|
|
75
78
|
authHandlers: neon.authHandlers,
|
|
76
79
|
handlers: () => createHandlers(context),
|
|
77
80
|
getUser: neon.getUser,
|
|
@@ -82,6 +85,7 @@ export function createApp(config, dependencies = {}) {
|
|
|
82
85
|
fetch: data.fetch,
|
|
83
86
|
warehouse: data.warehouse,
|
|
84
87
|
ontology: createOntologyClient(neon.ontologyTransport, data.aggregateTransport),
|
|
88
|
+
oauth: createAgentAuthorizations(neon.ontologyTransport),
|
|
85
89
|
};
|
|
86
90
|
}
|
|
87
91
|
/** Under Neon Auth the app has no SegmentStream session to call the API with. */
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export { NOT_INVITED, SIGN_IN_LINK_INVALID, SegmentStreamApiError } from "./erro
|
|
|
19
19
|
export { grants, isAllowed } from "./permissions.js";
|
|
20
20
|
export { sql } from "./parameters.js";
|
|
21
21
|
export type { AuthenticationMethod, SegmentStreamApp } from "./app.js";
|
|
22
|
+
export { CONSENT_PAGE } from "./oauth.js";
|
|
23
|
+
export type { AgentAuthorizationRequest, AgentAuthorizations, AgentScope } from "./oauth.js";
|
|
22
24
|
export type { ApiIssue } from "./errors.js";
|
|
23
25
|
export type { CurrentUser } from "./me.js";
|
|
24
26
|
export type { AuthRouteHandler, AuthRouteHandlers, SignOutOptions } from "./neon.js";
|
|
@@ -30,3 +32,4 @@ export type { SegmentStreamAppConfig } from "./config.js";
|
|
|
30
32
|
export type { SegmentStreamRouteHandlers, RouteHandler } from "./handlers.js";
|
|
31
33
|
export type { ProxyOptions, SegmentStreamProxy } from "./proxy.js";
|
|
32
34
|
export type { AppUser } from "./tokens.js";
|
|
35
|
+
export { createBlobStorageClient, type BlobUploadTicket, type FileRevision, type FileTransfer, type FileUploadTarget, type UploadSession, type FileUploadOptions, } from "./storage.js";
|
package/dist/index.js
CHANGED
|
@@ -19,3 +19,5 @@ export function createSegmentStreamApp(config) {
|
|
|
19
19
|
export { NOT_INVITED, SIGN_IN_LINK_INVALID, SegmentStreamApiError } from "./errors.js";
|
|
20
20
|
export { grants, isAllowed } from "./permissions.js";
|
|
21
21
|
export { sql } from "./parameters.js";
|
|
22
|
+
export { CONSENT_PAGE } from "./oauth.js";
|
|
23
|
+
export { createBlobStorageClient, } from "./storage.js";
|
package/dist/neon.js
CHANGED
|
@@ -14,6 +14,13 @@ import { isApiRequest, unauthenticatedResponse } from "./sign-in.js";
|
|
|
14
14
|
export const SIGN_IN_PAGE = "/sign-in";
|
|
15
15
|
/** Where `app/src/app/api/auth/[...path]/route.ts` serves the Neon Auth API. */
|
|
16
16
|
export const AUTH_API_PATH = "/api/auth";
|
|
17
|
+
/** The paths the ontology service answers: its HTTP API, its MCP server and its OAuth metadata. */
|
|
18
|
+
const ONTOLOGY_PATHS = [
|
|
19
|
+
"/api/ontology",
|
|
20
|
+
"/mcp",
|
|
21
|
+
"/.well-known/oauth-protected-resource",
|
|
22
|
+
"/.well-known/oauth-authorization-server",
|
|
23
|
+
];
|
|
17
24
|
/**
|
|
18
25
|
* Paths every visitor may open: the Neon Auth API, the sign-in page, the health check, and the
|
|
19
26
|
* Next.js assets and metadata files the sign-in page needs.
|
|
@@ -303,8 +310,9 @@ export function createNeonSession(dependencies) {
|
|
|
303
310
|
const publicPaths = [...OPEN_PATHS, ...(options.publicPaths ?? [])].map(normalizePublicPath);
|
|
304
311
|
return async function neonProxy(request) {
|
|
305
312
|
const { pathname, search } = request.nextUrl;
|
|
306
|
-
if (settings.ontologyUrl !== undefined &&
|
|
307
|
-
|
|
313
|
+
if (settings.ontologyUrl !== undefined &&
|
|
314
|
+
ONTOLOGY_PATHS.some((path) => matchesPathPrefix(pathname, path))) {
|
|
315
|
+
// Local development: the service runs elsewhere; deployed, its paths never reach the app.
|
|
308
316
|
return NextResponse.rewrite(new URL(`${pathname}${search}`, settings.ontologyUrl));
|
|
309
317
|
}
|
|
310
318
|
if (publicPaths.some((path) => matchesPathPrefix(pathname, path))) {
|
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { OntologyTransport } from "./ontology.js";
|
|
2
|
+
import { type SegmentStreamProxy } from "./proxy.js";
|
|
3
|
+
/** The app's page where a signed-in member decides whether an agent may act for them. */
|
|
4
|
+
export declare const CONSENT_PAGE = "/oauth/consent";
|
|
5
|
+
/**
|
|
6
|
+
* The proxy, with the consent page unframeable: no other site may show it in a frame and trick a
|
|
7
|
+
* member into clicking Allow. Every app gets this from the SDK, whatever its Next.js config says.
|
|
8
|
+
*/
|
|
9
|
+
export declare function withUnframeableConsent(proxy: SegmentStreamProxy): SegmentStreamProxy;
|
|
10
|
+
/** Everything the member may do, or only the reads of it. */
|
|
11
|
+
export type AgentScope = "ontology" | "ontology:read";
|
|
12
|
+
/** An agent asking to act for the signed-in member, as the consent page shows it. */
|
|
13
|
+
export interface AgentAuthorizationRequest {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly client: {
|
|
16
|
+
/** The name the agent registered with. Agents choose it themselves, so show the host too. */
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly uri: string | null;
|
|
19
|
+
/** Where the member's browser goes after the decision: the agent's own address. */
|
|
20
|
+
readonly redirectUri: string;
|
|
21
|
+
readonly redirectHost: string;
|
|
22
|
+
};
|
|
23
|
+
readonly scope: AgentScope;
|
|
24
|
+
/** What the member may do, and so the most the agent may do for them. */
|
|
25
|
+
readonly permissions: ReadonlyArray<string>;
|
|
26
|
+
readonly expiresAt: string;
|
|
27
|
+
}
|
|
28
|
+
export interface AgentAuthorizations {
|
|
29
|
+
/**
|
|
30
|
+
* The request the consent page at `/oauth/consent?request=<id>` shows, for the signed-in member;
|
|
31
|
+
* `null` when it expired or was already decided. Server-only.
|
|
32
|
+
*/
|
|
33
|
+
readonly request: (id: string) => Promise<AgentAuthorizationRequest | null>;
|
|
34
|
+
/**
|
|
35
|
+
* The Route Handler the consent page's form posts to, at `app/src/app/api/oauth/consent/route.ts`.
|
|
36
|
+
* It takes `request`, `decision` (`approve` or `deny`) and, to allow reading only, `scope`
|
|
37
|
+
* (`ontology:read`), and sends the browser back to the agent.
|
|
38
|
+
*/
|
|
39
|
+
readonly consentHandler: () => {
|
|
40
|
+
readonly POST: (request: Request) => Promise<Response>;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** The consent flow, over the ontology service as the signed-in member. */
|
|
44
|
+
export declare function createAgentAuthorizations(transport: OntologyTransport): AgentAuthorizations;
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { apiErrorFrom, readJson } from "./errors.js";
|
|
3
|
+
import { matchesPathPrefix } from "./proxy.js";
|
|
4
|
+
/** The app's page where a signed-in member decides whether an agent may act for them. */
|
|
5
|
+
export const CONSENT_PAGE = "/oauth/consent";
|
|
6
|
+
/**
|
|
7
|
+
* The proxy, with the consent page unframeable: no other site may show it in a frame and trick a
|
|
8
|
+
* member into clicking Allow. Every app gets this from the SDK, whatever its Next.js config says.
|
|
9
|
+
*/
|
|
10
|
+
export function withUnframeableConsent(proxy) {
|
|
11
|
+
return async (request) => {
|
|
12
|
+
const response = await proxy(request);
|
|
13
|
+
if (matchesPathPrefix(request.nextUrl.pathname, CONSENT_PAGE)) {
|
|
14
|
+
response.headers.set("x-frame-options", "DENY");
|
|
15
|
+
response.headers.set("content-security-policy", "frame-ancestors 'none'");
|
|
16
|
+
}
|
|
17
|
+
return response;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function parseRequest(payload) {
|
|
21
|
+
if (typeof payload !== "object" || payload === null) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const body = payload;
|
|
25
|
+
const client = (body.client ?? {});
|
|
26
|
+
if (typeof body.id !== "string" ||
|
|
27
|
+
typeof client.name !== "string" ||
|
|
28
|
+
typeof client.redirect_uri !== "string" ||
|
|
29
|
+
typeof client.redirect_host !== "string" ||
|
|
30
|
+
(body.scope !== "ontology" && body.scope !== "ontology:read") ||
|
|
31
|
+
!Array.isArray(body.permissions) ||
|
|
32
|
+
typeof body.expires_at !== "string") {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
id: body.id,
|
|
37
|
+
client: {
|
|
38
|
+
name: client.name,
|
|
39
|
+
uri: typeof client.uri === "string" ? client.uri : null,
|
|
40
|
+
redirectUri: client.redirect_uri,
|
|
41
|
+
redirectHost: client.redirect_host,
|
|
42
|
+
},
|
|
43
|
+
scope: body.scope,
|
|
44
|
+
permissions: body.permissions.filter((permission) => typeof permission === "string"),
|
|
45
|
+
expiresAt: body.expires_at,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Whether a form post came from the app's own pages. A decision must never be made from another
|
|
50
|
+
* site, which could otherwise approve an agent in the member's name.
|
|
51
|
+
*/
|
|
52
|
+
function sameOrigin(request) {
|
|
53
|
+
const origin = request.headers.get("origin");
|
|
54
|
+
if (origin !== null) {
|
|
55
|
+
return origin === new URL(request.url).origin;
|
|
56
|
+
}
|
|
57
|
+
return request.headers.get("sec-fetch-site") === "same-origin";
|
|
58
|
+
}
|
|
59
|
+
function requestPath(id, decision) {
|
|
60
|
+
const path = `/oauth/requests/${encodeURIComponent(id)}`;
|
|
61
|
+
return decision === undefined ? path : `${path}/${decision}`;
|
|
62
|
+
}
|
|
63
|
+
/** The consent flow, over the ontology service as the signed-in member. */
|
|
64
|
+
export function createAgentAuthorizations(transport) {
|
|
65
|
+
return {
|
|
66
|
+
async request(id) {
|
|
67
|
+
if (id === "") {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const response = await transport.fetch(requestPath(id), {
|
|
71
|
+
method: "GET",
|
|
72
|
+
headers: { accept: "application/json" },
|
|
73
|
+
});
|
|
74
|
+
const payload = await readJson(response);
|
|
75
|
+
if (response.status === 404) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw apiErrorFrom(response.status, payload, "The ontology service refused the request.");
|
|
80
|
+
}
|
|
81
|
+
return parseRequest(payload);
|
|
82
|
+
},
|
|
83
|
+
consentHandler: () => ({
|
|
84
|
+
async POST(request) {
|
|
85
|
+
if (!sameOrigin(request)) {
|
|
86
|
+
return NextResponse.json({ error: "forbidden", detail: "Decide on an agent from the app's own page." }, { status: 403 });
|
|
87
|
+
}
|
|
88
|
+
const form = await request.formData();
|
|
89
|
+
const id = String(form.get("request") ?? "");
|
|
90
|
+
const decision = form.get("decision") === "approve" ? "approve" : "deny";
|
|
91
|
+
const response = await transport.fetch(requestPath(id, decision), {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
94
|
+
body: JSON.stringify(form.get("scope") === "ontology:read" ? { scope: "ontology:read" } : {}),
|
|
95
|
+
});
|
|
96
|
+
const payload = await readJson(response);
|
|
97
|
+
if (response.status === 404) {
|
|
98
|
+
return NextResponse.redirect(new URL(`${CONSENT_PAGE}?expired=1`, request.url), 303);
|
|
99
|
+
}
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
throw apiErrorFrom(response.status, payload, "The ontology service refused the decision.");
|
|
102
|
+
}
|
|
103
|
+
const redirect = payload?.redirect;
|
|
104
|
+
if (typeof redirect !== "string" || !URL.canParse(redirect)) {
|
|
105
|
+
throw apiErrorFrom(502, payload, "The ontology service answered without a redirect.");
|
|
106
|
+
}
|
|
107
|
+
// 303: the agent's redirect URI receives a GET, whatever method brought the browser here.
|
|
108
|
+
return NextResponse.redirect(redirect, 303);
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
};
|
|
112
|
+
}
|
package/dist/ontology-wire.js
CHANGED
|
@@ -233,11 +233,13 @@ export function actionInputToWire(input, entity, action, catalog) {
|
|
|
233
233
|
const property = definition?.properties.find((candidate) => candidate.name === wireField);
|
|
234
234
|
return [
|
|
235
235
|
wireField,
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
236
|
+
property?.type === "blob" && isRecord(child)
|
|
237
|
+
? Object.fromEntries(Object.entries(child).map(([key, value]) => [toSnakeCase(key), value]))
|
|
238
|
+
: (action === "link" || action === "unlink") &&
|
|
239
|
+
field === "relationship" &&
|
|
240
|
+
typeof child === "string"
|
|
241
|
+
? toSnakeCase(child)
|
|
242
|
+
: selectToWire(child, property),
|
|
241
243
|
];
|
|
242
244
|
}));
|
|
243
245
|
}
|
package/dist/ontology.d.ts
CHANGED
|
@@ -121,6 +121,8 @@ export interface RequestOptions {
|
|
|
121
121
|
export interface ActionOptions extends RequestOptions {
|
|
122
122
|
/** Validate the input and report issues in `validation` without a write. */
|
|
123
123
|
readonly validateOnly?: boolean;
|
|
124
|
+
readonly idempotencyKey?: string;
|
|
125
|
+
readonly createIntent?: string;
|
|
124
126
|
}
|
|
125
127
|
export interface HistoryOptions extends RequestOptions {
|
|
126
128
|
readonly after?: string;
|
package/dist/ontology.js
CHANGED
|
@@ -89,9 +89,21 @@ function entityClient(entity, transport, getCatalog) {
|
|
|
89
89
|
const catalog = await getCatalog();
|
|
90
90
|
const wireResponse = await send(transport, "POST", `${base}/actions/${encodeURIComponent(action)}`, {
|
|
91
91
|
input: actionInputToWire(input, entity, action, catalog),
|
|
92
|
-
...(options.validateOnly === undefined
|
|
92
|
+
...([options.validateOnly, options.idempotencyKey, options.createIntent].every((value) => value === undefined)
|
|
93
93
|
? {}
|
|
94
|
-
: {
|
|
94
|
+
: {
|
|
95
|
+
options: {
|
|
96
|
+
...(options.validateOnly === undefined
|
|
97
|
+
? {}
|
|
98
|
+
: { validate_only: options.validateOnly }),
|
|
99
|
+
...(options.idempotencyKey === undefined
|
|
100
|
+
? {}
|
|
101
|
+
: { idempotency_key: options.idempotencyKey }),
|
|
102
|
+
...(options.createIntent === undefined
|
|
103
|
+
? {}
|
|
104
|
+
: { create_intent: options.createIntent }),
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
95
107
|
}, options.signal);
|
|
96
108
|
const response = { ...wireResponse, edits: editsFromWire(wireResponse.edits, catalog) };
|
|
97
109
|
if (action === "create" || action === "update") {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { OntologyTransport } from "./ontology.js";
|
|
2
|
+
export interface BlobUploadTicket {
|
|
3
|
+
readonly uploadId: string;
|
|
4
|
+
}
|
|
5
|
+
export interface FileRevision {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly filename: string;
|
|
8
|
+
readonly contentType: string;
|
|
9
|
+
readonly size: number;
|
|
10
|
+
readonly createdAt: string;
|
|
11
|
+
}
|
|
12
|
+
export interface FileTransfer {
|
|
13
|
+
readonly url: string;
|
|
14
|
+
readonly method: "GET" | "PUT";
|
|
15
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
16
|
+
readonly expiresAt: string;
|
|
17
|
+
}
|
|
18
|
+
export interface FileUploadTarget {
|
|
19
|
+
readonly entity: string;
|
|
20
|
+
readonly property: string;
|
|
21
|
+
readonly action: string;
|
|
22
|
+
readonly recordId?: string;
|
|
23
|
+
readonly createIntent?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface UploadSession {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly state: string;
|
|
28
|
+
readonly expiresAt: string;
|
|
29
|
+
readonly ticket?: BlobUploadTicket;
|
|
30
|
+
}
|
|
31
|
+
export interface FileUploadOptions {
|
|
32
|
+
readonly signal?: AbortSignal;
|
|
33
|
+
/** Reuse this UUID when retrying an upload and its entity action. */
|
|
34
|
+
readonly idempotencyKey?: string;
|
|
35
|
+
/** Reports transfer start and completion; supply a transfer adapter for granular progress. */
|
|
36
|
+
readonly onProgress?: (transferred: number, total: number) => void;
|
|
37
|
+
}
|
|
38
|
+
/** Portable client: ontology requests use the member transport; bytes use only a scoped grant. */
|
|
39
|
+
export declare function createBlobStorageClient(transport: OntologyTransport, transfer?: typeof fetch): {
|
|
40
|
+
status: (id: string, signal?: AbortSignal) => Promise<UploadSession>;
|
|
41
|
+
cancel: (id: string) => Promise<UploadSession>;
|
|
42
|
+
uploadForAction(target: FileUploadTarget, file: File, options?: FileUploadOptions): Promise<{
|
|
43
|
+
ticket: BlobUploadTicket;
|
|
44
|
+
options: {
|
|
45
|
+
createIntent?: string | undefined;
|
|
46
|
+
idempotencyKey: string;
|
|
47
|
+
};
|
|
48
|
+
}>;
|
|
49
|
+
download(entity: string, recordId: string, property: string, options?: {
|
|
50
|
+
readonly revisionId?: string;
|
|
51
|
+
readonly inline?: boolean;
|
|
52
|
+
readonly signal?: AbortSignal;
|
|
53
|
+
}): Promise<FileTransfer>;
|
|
54
|
+
};
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { apiErrorFrom, readJson } from "./errors.js";
|
|
2
|
+
import { toSnakeCase } from "./ontology-names.js";
|
|
3
|
+
/** Portable client: ontology requests use the member transport; bytes use only a scoped grant. */
|
|
4
|
+
export function createBlobStorageClient(transport, transfer = globalThis.fetch) {
|
|
5
|
+
const send = async (path, method, body, signal) => {
|
|
6
|
+
const response = await transport.fetch(path, {
|
|
7
|
+
method,
|
|
8
|
+
...(body === undefined
|
|
9
|
+
? {}
|
|
10
|
+
: { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }),
|
|
11
|
+
...(signal ? { signal } : {}),
|
|
12
|
+
});
|
|
13
|
+
const data = await readJson(response);
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
throw apiErrorFrom(response.status, data, "The ontology service rejected the file request.");
|
|
16
|
+
}
|
|
17
|
+
return data;
|
|
18
|
+
};
|
|
19
|
+
const sessionPath = (id) => `/uploads/${encodeURIComponent(id)}`;
|
|
20
|
+
const readSession = async (path, method, body, signal) => {
|
|
21
|
+
const { expires_at, ticket, ...session } = await send(path, method, body, signal);
|
|
22
|
+
return {
|
|
23
|
+
...session,
|
|
24
|
+
expiresAt: expires_at,
|
|
25
|
+
...(ticket ? { ticket: { uploadId: ticket.upload_id } } : {}),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
const readTransfer = async (path, body, signal) => {
|
|
29
|
+
const { expires_at, ...grant } = await send(path, "POST", body, signal);
|
|
30
|
+
return { ...grant, expiresAt: expires_at };
|
|
31
|
+
};
|
|
32
|
+
const cancel = (id) => readSession(`${sessionPath(id)}/cancel`, "POST");
|
|
33
|
+
return {
|
|
34
|
+
status: (id, signal) => readSession(sessionPath(id), "GET", undefined, signal),
|
|
35
|
+
cancel,
|
|
36
|
+
async uploadForAction(target, file, options = {}) {
|
|
37
|
+
const idempotencyKey = options.idempotencyKey ?? crypto.randomUUID();
|
|
38
|
+
const createIntent = target.recordId ? undefined : (target.createIntent ?? idempotencyKey);
|
|
39
|
+
const session = await readSession("/uploads", "POST", {
|
|
40
|
+
entity: toSnakeCase(target.entity),
|
|
41
|
+
property: toSnakeCase(target.property),
|
|
42
|
+
action: toSnakeCase(target.action),
|
|
43
|
+
...(target.recordId ? { record_id: target.recordId } : {}),
|
|
44
|
+
...(createIntent ? { create_intent: createIntent } : {}),
|
|
45
|
+
idempotency_key: idempotencyKey,
|
|
46
|
+
filename: file.name,
|
|
47
|
+
content_type: file.type,
|
|
48
|
+
size: file.size,
|
|
49
|
+
}, options.signal);
|
|
50
|
+
if (session.ticket) {
|
|
51
|
+
return {
|
|
52
|
+
ticket: session.ticket,
|
|
53
|
+
options: { idempotencyKey, ...(createIntent ? { createIntent } : {}) },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
let completionStarted = false;
|
|
57
|
+
let transferRejected = false;
|
|
58
|
+
try {
|
|
59
|
+
if (session.state !== "completing") {
|
|
60
|
+
const grant = await readTransfer(`${sessionPath(session.id)}/parts`, undefined, options.signal);
|
|
61
|
+
options.onProgress?.(0, file.size);
|
|
62
|
+
const response = await transfer(grant.url, {
|
|
63
|
+
method: grant.method,
|
|
64
|
+
headers: grant.headers,
|
|
65
|
+
body: file,
|
|
66
|
+
signal: options.signal,
|
|
67
|
+
credentials: "omit",
|
|
68
|
+
referrerPolicy: "no-referrer",
|
|
69
|
+
});
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
transferRejected = true;
|
|
72
|
+
throw new Error(`File transfer failed (${response.status}).`);
|
|
73
|
+
}
|
|
74
|
+
options.onProgress?.(file.size, file.size);
|
|
75
|
+
}
|
|
76
|
+
completionStarted = true;
|
|
77
|
+
const completed = await readSession(`${sessionPath(session.id)}/complete`, "POST", undefined, options.signal);
|
|
78
|
+
if (!completed.ticket) {
|
|
79
|
+
throw new Error("The file upload is not ready.");
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
ticket: completed.ticket,
|
|
83
|
+
options: { idempotencyKey, ...(createIntent ? { createIntent } : {}) },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (options.signal?.aborted || transferRejected) {
|
|
88
|
+
// Deliberate cancellation uses a fresh request even when the signal was aborted.
|
|
89
|
+
await cancel(session.id).catch((cause) => {
|
|
90
|
+
console.warn("ontology.upload.cancel_failed", { uploadId: session.id, cause });
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
else if (completionStarted) {
|
|
94
|
+
// A lost completion response may already have produced a valid ticket.
|
|
95
|
+
const recovered = await readSession(sessionPath(session.id), "GET").catch(() => undefined);
|
|
96
|
+
if (recovered?.ticket) {
|
|
97
|
+
return {
|
|
98
|
+
ticket: recovered.ticket,
|
|
99
|
+
options: { idempotencyKey, ...(createIntent ? { createIntent } : {}) },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Ambiguous network failures retain the session for an explicit retry or scheduled cleanup.
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
download(entity, recordId, property, options = {}) {
|
|
108
|
+
const { signal } = options;
|
|
109
|
+
const input = {
|
|
110
|
+
...(options.revisionId ? { revision_id: options.revisionId } : {}),
|
|
111
|
+
...(options.inline === undefined ? {} : { inline: options.inline }),
|
|
112
|
+
};
|
|
113
|
+
return readTransfer(`/${encodeURIComponent(toSnakeCase(entity))}/${encodeURIComponent(recordId)}/blobs/${encodeURIComponent(toSnakeCase(property))}/download`, input, signal);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream-cloud/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Sign-in, users, permissions, the ontology client and warehouse data access for SegmentStream workspace apps built with Next.js.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "SegmentStream",
|
|
@@ -27,7 +27,11 @@
|
|
|
27
27
|
"types": "./dist/ui/index.d.ts",
|
|
28
28
|
"import": "./dist/ui/index.js"
|
|
29
29
|
},
|
|
30
|
-
"./ui/styles.css": "./dist/ui/styles.css"
|
|
30
|
+
"./ui/styles.css": "./dist/ui/styles.css",
|
|
31
|
+
"./storage": {
|
|
32
|
+
"types": "./dist/storage.d.ts",
|
|
33
|
+
"import": "./dist/storage.js"
|
|
34
|
+
}
|
|
31
35
|
},
|
|
32
36
|
"files": [
|
|
33
37
|
"dist",
|