@asaidimu/hestia 1.0.0 → 1.0.1
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/auth/store.ts +2 -2
- package/auth/types.ts +1 -1
- package/blobs/store.ts +58 -57
- package/collections/store.ts +53 -24
- package/collections/types.ts +1 -1
- package/container.ts +7 -9
- package/core/client.ts +170 -9
- package/core/collection.ts +33 -9
- package/core/errors.ts +1 -1
- package/core/pager.ts +3 -0
- package/core/types.ts +2 -2
- package/index.ts +18 -41
- package/package.json +6 -2
- package/system/api-keys/store.ts +47 -10
- package/system/api-keys/types.ts +5 -3
- package/system/capabilities/store.ts +57 -13
- package/system/identity/store.ts +35 -12
- package/system/identity/types.ts +5 -5
- package/system/policies/store.ts +138 -35
- package/system/policies/types.ts +2 -2
- package/utils/pager.ts +8 -4
- package/system/logs/store.ts +0 -147
- package/system/logs/types.ts +0 -38
package/auth/store.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { HestiaNetworkClient, IdentityProvider } from "../core/client";
|
|
1
|
+
import { HestiaNetworkClient, type IdentityProvider } from "../core/client";
|
|
2
2
|
import type { LoginResult, ServerHealth, TokenPair } from "./types";
|
|
3
3
|
|
|
4
4
|
export class HestiaAuth {
|
|
@@ -29,7 +29,7 @@ export class HestiaAuth {
|
|
|
29
29
|
name: string,
|
|
30
30
|
): Promise<{ _id_: string; email: string; name: string }> {
|
|
31
31
|
const res = await this.client.post<{
|
|
32
|
-
data: { _id_: string; email: string; name: string,
|
|
32
|
+
data: { _id_: string; email: string; name: string, permissions: string[] };
|
|
33
33
|
}>("/system/auth/user", { email, password, name });
|
|
34
34
|
return res.data!.data;
|
|
35
35
|
}
|
package/auth/types.ts
CHANGED
package/blobs/store.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { QueryDSL } from "@asaidimu/query";
|
|
|
2
2
|
import { ReactiveDataStore } from "@asaidimu/utils-store";
|
|
3
3
|
import { HestiaNetworkClient } from "../core/client";
|
|
4
4
|
import { createPagedController } from "../core/pager";
|
|
5
|
-
import type { Page, PagedData } from "../core/types";
|
|
5
|
+
import type { Document, Page, PagedData, StoreEvent } from "../core/types";
|
|
6
|
+
import type { DocumentStore } from "../core/types";
|
|
6
7
|
import type {
|
|
7
8
|
BlobDocument,
|
|
8
9
|
BlobMeta,
|
|
@@ -34,12 +35,7 @@ function pageMeta<T extends Record<string,any>>(items: T[]): Page<T>["page"] {
|
|
|
34
35
|
};
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
* Namespace-scoped blob facade shaped like HestiaCollection.
|
|
39
|
-
* Wraps blob CRUD behind a DocumentStore-compatible interface
|
|
40
|
-
* so you can use DataTable + PagedData.
|
|
41
|
-
*/
|
|
42
|
-
export class BlobNamespace {
|
|
38
|
+
export class BlobNamespace implements DocumentStore<BlobMeta, QueryDSL<BlobMeta>, string, QueryDSL<BlobMeta>, Record<string, unknown>, string, Record<string, any>, Record<string, unknown>, { key: string; contentType?: string }, Record<string, unknown>> {
|
|
43
39
|
private pagerOptions = {};
|
|
44
40
|
private pager: PagedData<BlobMeta>;
|
|
45
41
|
private prefixFilter = "";
|
|
@@ -85,7 +81,7 @@ export class BlobNamespace {
|
|
|
85
81
|
return { data: items.map(asDoc), loading: false, page: pageMeta(items), error: undefined };
|
|
86
82
|
}
|
|
87
83
|
|
|
88
|
-
async
|
|
84
|
+
async read(key: string): Promise<Document<BlobMeta> | undefined> {
|
|
89
85
|
try {
|
|
90
86
|
const res = await this.client.get<{ data: BlobMeta }>(
|
|
91
87
|
`${this.basePath()}/${encodeURIComponent(key)}`,
|
|
@@ -104,76 +100,83 @@ export class BlobNamespace {
|
|
|
104
100
|
}
|
|
105
101
|
}
|
|
106
102
|
|
|
107
|
-
async
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
contentType?: string,
|
|
111
|
-
): Promise<BlobDocument> {
|
|
112
|
-
const headers: Record<string, string> = {};
|
|
113
|
-
const ct = contentType || data.type;
|
|
114
|
-
if (ct) headers["Content-Type"] = ct;
|
|
103
|
+
async create(_props: { data: Partial<BlobMeta> }): Promise<Document<BlobMeta> | undefined> {
|
|
104
|
+
throw new Error("Use upload() to create blobs");
|
|
105
|
+
}
|
|
115
106
|
|
|
116
|
-
|
|
107
|
+
async update(props: { data: Partial<BlobMeta>; options?: Record<string, any> }): Promise<Document<BlobMeta> | undefined> {
|
|
108
|
+
const key = props.options?.key as string;
|
|
109
|
+
if (!key) throw new Error("options.key is required for blob update");
|
|
110
|
+
const res = await this.client.patch<{ data: BlobMeta }>(
|
|
117
111
|
`${this.basePath()}/${encodeURIComponent(key)}`,
|
|
118
|
-
data,
|
|
119
|
-
{ headers, bodyType: "blob" },
|
|
112
|
+
{ custom: props.data },
|
|
120
113
|
);
|
|
121
114
|
return asDoc(res.data!.data);
|
|
122
115
|
}
|
|
123
116
|
|
|
124
|
-
async
|
|
125
|
-
|
|
126
|
-
): Promise<{ data: Blob; contentType: string }> {
|
|
127
|
-
const res = await this.client.get<Blob>(
|
|
117
|
+
async delete(key: string): Promise<void> {
|
|
118
|
+
await this.client.delete(
|
|
128
119
|
`${this.basePath()}/${encodeURIComponent(key)}`,
|
|
129
|
-
{ responseType: "blob" },
|
|
130
120
|
);
|
|
131
|
-
const blob = res.data!;
|
|
132
|
-
return { data: blob, contentType: blob.type };
|
|
133
121
|
}
|
|
134
122
|
|
|
135
|
-
async
|
|
136
|
-
|
|
137
|
-
`${this.basePath()}/${encodeURIComponent(key)}`,
|
|
138
|
-
{ custom },
|
|
139
|
-
);
|
|
140
|
-
return res.data!.data;
|
|
123
|
+
async list(options?: QueryDSL<BlobMeta>): Promise<Page<BlobMeta>> {
|
|
124
|
+
return this.find(options ?? {});
|
|
141
125
|
}
|
|
142
126
|
|
|
143
|
-
async
|
|
144
|
-
|
|
127
|
+
async upload(props: { file: File; options?: { key?: string; contentType?: string } }): Promise<Document<BlobMeta> | undefined> {
|
|
128
|
+
const key = (props.options as any)?.key as string;
|
|
129
|
+
if (!key) throw new Error("options.key is required for blob upload");
|
|
130
|
+
const headers: Record<string, string> = {};
|
|
131
|
+
const ct = props.options?.contentType || props.file.type;
|
|
132
|
+
if (ct) headers["Content-Type"] = ct;
|
|
133
|
+
|
|
134
|
+
const res = await this.client.post<{ data: BlobMeta }>(
|
|
145
135
|
`${this.basePath()}/${encodeURIComponent(key)}`,
|
|
136
|
+
props.file,
|
|
137
|
+
{ headers, bodyType: "blob" },
|
|
146
138
|
);
|
|
139
|
+
return asDoc(res.data!.data);
|
|
147
140
|
}
|
|
148
141
|
|
|
149
|
-
async
|
|
150
|
-
|
|
151
|
-
data: { blobs: BlobMeta[] };
|
|
152
|
-
}>(`${this.basePath()}/query`, {});
|
|
153
|
-
return res.data?.data?.blobs ?? [];
|
|
142
|
+
async subscribe(_scope: string, _callback: (event: StoreEvent) => void): Promise<() => void> {
|
|
143
|
+
throw new Error("Subscription not supported for blobs");
|
|
154
144
|
}
|
|
155
145
|
|
|
156
|
-
|
|
146
|
+
async notify(_event: StoreEvent): Promise<void> {
|
|
147
|
+
throw new Error("Notify not supported for blobs");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
stream(_options: Record<string, unknown>, _onStreamChange: () => void): {
|
|
151
|
+
stream: () => AsyncIterable<Document<BlobMeta>>;
|
|
152
|
+
cancel: () => void;
|
|
153
|
+
status: () => "active" | "cancelled" | "completed";
|
|
154
|
+
} {
|
|
155
|
+
throw new Error("Stream not supported for blobs");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
page(_options?: Record<string, unknown>): PagedData<BlobMeta> {
|
|
157
159
|
return this.pager;
|
|
158
160
|
}
|
|
161
|
+
|
|
162
|
+
async download(key: string): Promise<{ data: Blob; contentType: string }> {
|
|
163
|
+
const res = await this.client.get<Blob>(
|
|
164
|
+
`${this.basePath()}/${encodeURIComponent(key)}`,
|
|
165
|
+
{ responseType: "blob" },
|
|
166
|
+
);
|
|
167
|
+
const blob = res.data!;
|
|
168
|
+
return { data: blob, contentType: blob.type };
|
|
169
|
+
}
|
|
159
170
|
}
|
|
160
171
|
|
|
161
|
-
/**
|
|
162
|
-
* Top-level blob client. Entry point for all blob operations.
|
|
163
|
-
*
|
|
164
|
-
* Usage:
|
|
165
|
-
* client.blobs.listNamespaces()
|
|
166
|
-
* const ns = client.blobs.namespace("my-bucket")
|
|
167
|
-
* ns.find({ prefix: "images/" })
|
|
168
|
-
* ns.upload("logo.png", file)
|
|
169
|
-
* const { data } = await ns.download("logo.png")
|
|
170
|
-
*/
|
|
171
172
|
export class HestiaBlobClient {
|
|
172
|
-
|
|
173
|
+
private apiPrefix: string;
|
|
173
174
|
|
|
174
|
-
private
|
|
175
|
+
constructor(private client: HestiaNetworkClient, apiPrefix: string = "/api") {
|
|
176
|
+
this.apiPrefix = apiPrefix;
|
|
177
|
+
}
|
|
175
178
|
|
|
176
|
-
|
|
179
|
+
private nsBase = "/system/blobs";
|
|
177
180
|
|
|
178
181
|
async namespaces(): Promise<NamespaceInfo[]> {
|
|
179
182
|
const res = await this.client.post<{
|
|
@@ -196,13 +199,11 @@ export class HestiaBlobClient {
|
|
|
196
199
|
);
|
|
197
200
|
}
|
|
198
201
|
|
|
199
|
-
|
|
200
202
|
blob(namespace: string, key:string) {
|
|
201
|
-
return `${this.client.base()}${this.nsBase}/blob/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`
|
|
203
|
+
return `${this.client.base()}${this.apiPrefix}${this.nsBase}/blob/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`
|
|
202
204
|
}
|
|
203
|
-
// ── Namespace-scoped facade ───────────────────────────────────────────
|
|
204
205
|
|
|
205
206
|
namespace(ns: string): BlobNamespace {
|
|
206
207
|
return new BlobNamespace(this.client, ns);
|
|
207
208
|
}
|
|
208
|
-
}
|
|
209
|
+
}
|
package/collections/store.ts
CHANGED
|
@@ -1,59 +1,88 @@
|
|
|
1
1
|
import { HestiaNetworkClient } from "../core/client"
|
|
2
2
|
import { HestiaCollection } from "../core/collection"
|
|
3
|
-
import type { Document } from "../core/types"
|
|
3
|
+
import type { Document, Page, PagedData, StoreEvent } from "../core/types"
|
|
4
|
+
import type { DocumentStore } from "../core/types"
|
|
4
5
|
import type { CollectionMeta } from "./types"
|
|
5
6
|
|
|
6
|
-
export class HestiaCollections {
|
|
7
|
+
export class HestiaCollections implements DocumentStore<CollectionMeta, Record<string, unknown>, string, Record<string, unknown>, Record<string, unknown>, string, string, Record<string, unknown>> {
|
|
7
8
|
constructor(private client: HestiaNetworkClient) {}
|
|
8
9
|
|
|
9
|
-
async
|
|
10
|
+
async find(_query?: Record<string, unknown>): Promise<Page<CollectionMeta>> {
|
|
10
11
|
const res = await this.client.get<{
|
|
11
12
|
data: { name: string; schema: any; created: string; updated: string }[]
|
|
12
13
|
}>("/system/collections/collection")
|
|
13
14
|
const items = res.data?.data ?? []
|
|
15
|
+
const docs: Document<CollectionMeta>[] = items.map((i) => ({
|
|
16
|
+
_id_: i.name,
|
|
17
|
+
_metadata_: { checksum: "", created: i.created, updated: i.updated, version: 1 },
|
|
18
|
+
name: i.name,
|
|
19
|
+
schema: i.schema,
|
|
20
|
+
created: i.created,
|
|
21
|
+
updated: i.updated,
|
|
22
|
+
}))
|
|
14
23
|
return {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
created: i.created,
|
|
19
|
-
updated: i.updated,
|
|
20
|
-
})),
|
|
21
|
-
total: items.length,
|
|
24
|
+
data: docs,
|
|
25
|
+
loading: false,
|
|
26
|
+
page: { number: 1, size: docs.length, count: docs.length, total: docs.length, pages: 1 },
|
|
22
27
|
}
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
async read(name: string): Promise<CollectionMeta | undefined> {
|
|
30
|
+
async read(name: string): Promise<Document<CollectionMeta> | undefined> {
|
|
26
31
|
try {
|
|
27
32
|
const res = await this.client.get<{ data: { name: string; schema: any; created: string; updated: string } }>(
|
|
28
33
|
`/system/collections/collection/${encodeURIComponent(name)}`,
|
|
29
34
|
)
|
|
30
35
|
if (!res.data) return undefined
|
|
31
|
-
|
|
36
|
+
const d = res.data.data
|
|
37
|
+
return { _id_: d.name, _metadata_: { checksum: "", created: d.created, updated: d.updated, version: 1 }, name: d.name, schema: d.schema, created: d.created, updated: d.updated }
|
|
32
38
|
} catch (err: any) {
|
|
33
39
|
if (err?.code === "SYNC-001-NF") return undefined
|
|
34
40
|
throw err
|
|
35
41
|
}
|
|
36
42
|
}
|
|
37
43
|
|
|
38
|
-
async create(
|
|
39
|
-
const res = await this.client.post<{ data: Document<{ schema: any }> }>("/system/collections/collection",
|
|
40
|
-
return res.data!.data
|
|
44
|
+
async create(props: { data: Partial<CollectionMeta> }): Promise<Document<CollectionMeta> | undefined> {
|
|
45
|
+
const res = await this.client.post<{ data: Document<{ schema: any }> }>("/system/collections/collection", props.data)
|
|
46
|
+
return res.data!.data as any as Document<CollectionMeta>
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
async update(
|
|
44
|
-
|
|
45
|
-
// const res = await this.client.patch<{ data: Document<{ schema: any }> }>(
|
|
46
|
-
// `/api/admin/collections/${encodeURIComponent(name)}`,
|
|
47
|
-
// schema,
|
|
48
|
-
// )
|
|
49
|
-
// return res.data!.data
|
|
49
|
+
async update(_props: { data: Partial<CollectionMeta>; options?: string }): Promise<Document<CollectionMeta> | undefined> {
|
|
50
|
+
throw new Error("Collection update not implemented")
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
async delete(name: string): Promise<void> {
|
|
53
54
|
await this.client.delete(`/system/collections/collection/${encodeURIComponent(name)}`)
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
async list(_options?: Record<string, unknown>): Promise<Page<CollectionMeta>> {
|
|
58
|
+
return this.find()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async upload(_props: { file: File }): Promise<Document<CollectionMeta> | undefined> {
|
|
62
|
+
throw new Error("Upload not supported for collections")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async subscribe(_scope: string, _callback: (event: StoreEvent) => void): Promise<() => void> {
|
|
66
|
+
throw new Error("Subscription not supported for collections")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async notify(_event: StoreEvent): Promise<void> {
|
|
70
|
+
throw new Error("Notify not supported for collections")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
stream(_options: Record<string, unknown>, _onStreamChange: () => void): {
|
|
74
|
+
stream: () => AsyncIterable<Document<CollectionMeta>>;
|
|
75
|
+
cancel: () => void;
|
|
76
|
+
status: () => "active" | "cancelled" | "completed";
|
|
77
|
+
} {
|
|
78
|
+
throw new Error("Stream not supported for collections")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
page(_options?: Record<string, unknown>): PagedData<CollectionMeta> {
|
|
82
|
+
throw new Error("Pagination not supported for collection metadata; use documents(name)")
|
|
83
|
+
}
|
|
84
|
+
|
|
56
85
|
documents<T extends Record<string, any>>(collectionName: string): HestiaCollection<T> {
|
|
57
|
-
return new HestiaCollection<T>(this.client,collectionName)
|
|
86
|
+
return new HestiaCollection<T>(this.client, collectionName)
|
|
58
87
|
}
|
|
59
|
-
}
|
|
88
|
+
}
|
package/collections/types.ts
CHANGED
package/container.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { ArtifactContainer } from "@asaidimu/utils-artifacts";
|
|
2
1
|
import type { SimplePersistence } from "@asaidimu/utils-persistence";
|
|
3
2
|
import { ReactiveDataStore } from "@asaidimu/utils-store";
|
|
4
3
|
import { HestiaAuth } from "./auth/store";
|
|
@@ -9,7 +8,7 @@ import {
|
|
|
9
8
|
} from "./core/client";
|
|
10
9
|
import { HestiaKeyStore } from "./system/api-keys/store";
|
|
11
10
|
import { HestiaUsers } from "./system/identity/store";
|
|
12
|
-
import { UserIdentity } from "./system/identity/types";
|
|
11
|
+
import type { UserIdentity } from "./system/identity/types";
|
|
13
12
|
import { HestiaLogs } from "./system/logs/store";
|
|
14
13
|
import { HestiaPolicies } from "./system/policies/store";
|
|
15
14
|
import { HestiaBlobClient } from "./blobs/store";
|
|
@@ -17,6 +16,7 @@ import { HestiaCapabilities } from "./system/capabilities/store";
|
|
|
17
16
|
|
|
18
17
|
export interface HestiaConfig {
|
|
19
18
|
baseUrl: string;
|
|
19
|
+
apiPrefix?: string;
|
|
20
20
|
persistence?: SimplePersistence<AuthState>;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -26,11 +26,8 @@ interface AuthState {
|
|
|
26
26
|
identity: UserIdentity | null;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
type Registry = Record<string, any>;
|
|
30
|
-
|
|
31
29
|
export class HestiaClient {
|
|
32
30
|
readonly store: ReactiveDataStore<AuthState>;
|
|
33
|
-
readonly container: ArtifactContainer<Registry, any>;
|
|
34
31
|
readonly client: HestiaNetworkClient;
|
|
35
32
|
readonly auth: HestiaAuth;
|
|
36
33
|
readonly users: HestiaUsers;
|
|
@@ -50,8 +47,6 @@ export class HestiaClient {
|
|
|
50
47
|
config.persistence,
|
|
51
48
|
);
|
|
52
49
|
|
|
53
|
-
this.container = new ArtifactContainer<Registry, any>(this.store);
|
|
54
|
-
|
|
55
50
|
const tokenProvider: IdentityProvider = {
|
|
56
51
|
identity: () => this.store.get().identity,
|
|
57
52
|
token: (key: "access" | "refresh") => this.store.get()[key],
|
|
@@ -63,8 +58,10 @@ export class HestiaClient {
|
|
|
63
58
|
void (await this.store.set({ access: null, refresh: null })),
|
|
64
59
|
};
|
|
65
60
|
|
|
61
|
+
const apiPrefix = config.apiPrefix ?? "/api";
|
|
62
|
+
|
|
66
63
|
this.tokenProvider = tokenProvider;
|
|
67
|
-
this.client = new HestiaNetworkClient(config.baseUrl, tokenProvider, () =>
|
|
64
|
+
this.client = new HestiaNetworkClient(config.baseUrl, apiPrefix, tokenProvider, () =>
|
|
68
65
|
this.onAuthStateChanged?.(),
|
|
69
66
|
);
|
|
70
67
|
|
|
@@ -75,9 +72,10 @@ export class HestiaClient {
|
|
|
75
72
|
this.logs = new HestiaLogs(
|
|
76
73
|
this.client,
|
|
77
74
|
config.baseUrl,
|
|
75
|
+
apiPrefix,
|
|
78
76
|
);
|
|
79
77
|
this.collections = new HestiaCollections(this.client);
|
|
80
|
-
this.blobs = new HestiaBlobClient(this.client);
|
|
78
|
+
this.blobs = new HestiaBlobClient(this.client, apiPrefix);
|
|
81
79
|
this.capabilities = new HestiaCapabilities(this.client)
|
|
82
80
|
}
|
|
83
81
|
|
package/core/client.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
} from "@asaidimu/network-client";
|
|
6
6
|
import { SystemError } from "@asaidimu/utils-error";
|
|
7
7
|
import { parseErrorBody, toSystemError } from "./errors";
|
|
8
|
-
import { UserIdentity } from "../system/identity/types";
|
|
8
|
+
import type { UserIdentity } from "../system/identity/types";
|
|
9
9
|
|
|
10
10
|
export interface IdentityProvider {
|
|
11
11
|
identity(): UserIdentity | null;
|
|
@@ -39,6 +39,22 @@ interface RequestOptions {
|
|
|
39
39
|
signal?: AbortSignal;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Handlers driving a live server-sent-events stream opened via
|
|
43
|
+
// HestiaNetworkClient.openStream(). onMessage fires once per parsed SSE
|
|
44
|
+
// "data:" payload (still a raw string — callers decide whether/how to
|
|
45
|
+
// JSON.parse it); onOpen/onClose/onError report connection lifecycle.
|
|
46
|
+
export interface StreamHandlers {
|
|
47
|
+
onMessage: (data: string) => void;
|
|
48
|
+
onError?: (err: Error) => void;
|
|
49
|
+
onOpen?: () => void;
|
|
50
|
+
onClose?: () => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface StreamOptions {
|
|
54
|
+
headers?: Record<string, string>;
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
export class HestiaNetworkClient {
|
|
43
59
|
private raw: NetworkClient;
|
|
44
60
|
private refreshing: Promise<void> | null = null;
|
|
@@ -46,6 +62,7 @@ export class HestiaNetworkClient {
|
|
|
46
62
|
|
|
47
63
|
constructor(
|
|
48
64
|
private baseUrl: string,
|
|
65
|
+
private apiPrefix: string,
|
|
49
66
|
private tokens: IdentityProvider,
|
|
50
67
|
private onAuthStateChanged?: () => void,
|
|
51
68
|
) {
|
|
@@ -70,12 +87,39 @@ export class HestiaNetworkClient {
|
|
|
70
87
|
base() {
|
|
71
88
|
return this.baseUrl;
|
|
72
89
|
}
|
|
90
|
+
|
|
91
|
+
prefix(): string {
|
|
92
|
+
return this.apiPrefix;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Single entry-point for all path manipulation (prefix handling).
|
|
96
|
+
// Returns the relative path that should be appended to the base URL.
|
|
97
|
+
private canonicalPath(path: string): string {
|
|
98
|
+
// 1. Strip any leading slashes so we work with a clean base (e.g., "api/api/system" or "/api/system")
|
|
99
|
+
let cleanPath = path.replace(/^\/+/, "");
|
|
100
|
+
|
|
101
|
+
if (this.apiPrefix) {
|
|
102
|
+
// 2. Strip leading slashes from the prefix too, just to be safe and consistent
|
|
103
|
+
const cleanPrefix = this.apiPrefix.replace(/^\/+/, "");
|
|
104
|
+
|
|
105
|
+
// 3. If the path already starts with the prefix followed by a slash (or is exactly the prefix),
|
|
106
|
+
// remove it so we don't double up.
|
|
107
|
+
const prefixRegex = new RegExp(`^${cleanPrefix}/?`);
|
|
108
|
+
cleanPath = cleanPath.replace(prefixRegex, "");
|
|
109
|
+
|
|
110
|
+
// 4. Combine them cleanly
|
|
111
|
+
return `${cleanPrefix}/${cleanPath}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return cleanPath;
|
|
115
|
+
}
|
|
116
|
+
|
|
73
117
|
async storeTokens(access: string, refresh?: string): Promise<void> {
|
|
74
118
|
this.refreshFailed = false;
|
|
75
119
|
await this.tokens.setTokens(access, refresh ?? "");
|
|
76
120
|
}
|
|
77
121
|
|
|
78
|
-
private
|
|
122
|
+
private async refreshToken(): Promise<void> {
|
|
79
123
|
if (this.refreshing) return this.refreshing;
|
|
80
124
|
|
|
81
125
|
this.refreshing = this.doRefresh();
|
|
@@ -92,7 +136,7 @@ export class HestiaNetworkClient {
|
|
|
92
136
|
const body = refresh ? { refresh_token: refresh } : {};
|
|
93
137
|
const res = await this.raw.patch<{
|
|
94
138
|
data: { token: { access: string; refresh: string } };
|
|
95
|
-
}>("/system/auth/session", body);
|
|
139
|
+
}>(this.canonicalPath("/system/auth/session"), body);
|
|
96
140
|
|
|
97
141
|
if (!res.success || !res.data) {
|
|
98
142
|
this.tokens.clear();
|
|
@@ -114,6 +158,8 @@ export class HestiaNetworkClient {
|
|
|
114
158
|
body?: unknown,
|
|
115
159
|
options?: RequestOptions,
|
|
116
160
|
): Promise<HestiaResponse<T>> {
|
|
161
|
+
const fullPath = this.canonicalPath(path);
|
|
162
|
+
|
|
117
163
|
const opts: any = {};
|
|
118
164
|
if (options?.headers) opts.headers = options.headers;
|
|
119
165
|
if (options?.responseType) opts.responseType = options.responseType;
|
|
@@ -123,13 +169,13 @@ export class HestiaNetworkClient {
|
|
|
123
169
|
let res: ApiResponse<T>;
|
|
124
170
|
|
|
125
171
|
if (method === "GET") {
|
|
126
|
-
res = await this.raw.get<T>(
|
|
172
|
+
res = await this.raw.get<T>(fullPath, opts);
|
|
127
173
|
} else {
|
|
128
174
|
const bodyOpts = options?.bodyType
|
|
129
175
|
? { type: options.bodyType as BodyType }
|
|
130
176
|
: undefined;
|
|
131
177
|
res = (await (this.raw as any)[method.toLowerCase()](
|
|
132
|
-
|
|
178
|
+
fullPath,
|
|
133
179
|
body,
|
|
134
180
|
opts,
|
|
135
181
|
bodyOpts,
|
|
@@ -153,7 +199,7 @@ export class HestiaNetworkClient {
|
|
|
153
199
|
});
|
|
154
200
|
}
|
|
155
201
|
|
|
156
|
-
// If a previous refresh already failed, don
|
|
202
|
+
// If a previous refresh already failed, don’t loop — the tokens
|
|
157
203
|
// (including any cookie fallback) are known to be dead.
|
|
158
204
|
if (this.refreshFailed) {
|
|
159
205
|
this.tokens.clear();
|
|
@@ -180,17 +226,20 @@ export class HestiaNetworkClient {
|
|
|
180
226
|
const newAccess = this.tokens.token("access");
|
|
181
227
|
const retryOpts: any = { ...opts };
|
|
182
228
|
if (newAccess) {
|
|
183
|
-
retryOpts.headers = {
|
|
229
|
+
retryOpts.headers = {
|
|
230
|
+
...(opts.headers ?? {}),
|
|
231
|
+
Authorization: `Bearer ${newAccess}`,
|
|
232
|
+
};
|
|
184
233
|
}
|
|
185
234
|
|
|
186
235
|
if (method === "GET") {
|
|
187
|
-
res = await this.raw.get<T>(
|
|
236
|
+
res = await this.raw.get<T>(fullPath, retryOpts);
|
|
188
237
|
} else {
|
|
189
238
|
const bodyOpts = options?.bodyType
|
|
190
239
|
? { type: options.bodyType as BodyType }
|
|
191
240
|
: undefined;
|
|
192
241
|
res = (await (this.raw as any)[method.toLowerCase()](
|
|
193
|
-
|
|
242
|
+
fullPath,
|
|
194
243
|
body,
|
|
195
244
|
retryOpts,
|
|
196
245
|
bodyOpts,
|
|
@@ -244,4 +293,116 @@ export class HestiaNetworkClient {
|
|
|
244
293
|
): Promise<HestiaResponse<T>> {
|
|
245
294
|
return this.request<T>("DELETE", path, body, options);
|
|
246
295
|
}
|
|
296
|
+
|
|
297
|
+
// Opens an authenticated, long-lived GET request against a
|
|
298
|
+
// "text/event-stream" endpoint (e.g. system:audit:log:stream) and parses
|
|
299
|
+
// standard SSE framing ("data: ...\n\n") off the raw response body.
|
|
300
|
+
//
|
|
301
|
+
// Bypasses `raw` deliberately: the wrapped network-client's request/
|
|
302
|
+
// response cycle is built around single JSON responses, not an
|
|
303
|
+
// indefinitely-open ReadableStream, so this talks to `fetch` directly.
|
|
304
|
+
// On a 401 it will attempt exactly one token refresh and reconnect once
|
|
305
|
+
// before giving up — mirroring `request()`'s retry behavior, but capped
|
|
306
|
+
// at a single attempt since a stream that keeps failing auth shouldn’t
|
|
307
|
+
// loop indefinitely in the background.
|
|
308
|
+
async openStream(
|
|
309
|
+
path: string,
|
|
310
|
+
handlers: StreamHandlers,
|
|
311
|
+
options?: StreamOptions,
|
|
312
|
+
): Promise<void> {
|
|
313
|
+
const attempt = async (isRetry: boolean): Promise<void> => {
|
|
314
|
+
const access = this.tokens.token("access");
|
|
315
|
+
const isApiKeyAuth = !!options?.headers?.["X-API-Key"];
|
|
316
|
+
const headers: Record<string, string> = {
|
|
317
|
+
Accept: "text/event-stream",
|
|
318
|
+
...(access && !isApiKeyAuth
|
|
319
|
+
? { Authorization: `Bearer ${access}` }
|
|
320
|
+
: {}),
|
|
321
|
+
...(options?.headers ?? {}),
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const url = `${this.baseUrl.replace(/\/+$/, "")}/${this.canonicalPath(path)}`;
|
|
325
|
+
|
|
326
|
+
let response: Response;
|
|
327
|
+
try {
|
|
328
|
+
response = await fetch(url, {
|
|
329
|
+
method: "GET",
|
|
330
|
+
headers,
|
|
331
|
+
signal: options?.signal,
|
|
332
|
+
});
|
|
333
|
+
} catch (err) {
|
|
334
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
335
|
+
handlers.onClose?.();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (response.status === 401 && !isRetry && !isApiKeyAuth) {
|
|
343
|
+
try {
|
|
344
|
+
await this.refreshToken();
|
|
345
|
+
} catch {
|
|
346
|
+
this.tokens.clear();
|
|
347
|
+
this.onAuthStateChanged?.();
|
|
348
|
+
handlers.onError?.(
|
|
349
|
+
new SystemError({
|
|
350
|
+
code: "AUTH-002-UNAUTH",
|
|
351
|
+
message: "Session expired",
|
|
352
|
+
}),
|
|
353
|
+
);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
return attempt(true);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!response.ok || !response.body) {
|
|
360
|
+
handlers.onError?.(
|
|
361
|
+
new Error(`Stream request failed with status ${response.status}`),
|
|
362
|
+
);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
handlers.onOpen?.();
|
|
367
|
+
|
|
368
|
+
const reader = response.body.getReader();
|
|
369
|
+
const decoder = new TextDecoder();
|
|
370
|
+
let buffer = "";
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
while (true) {
|
|
374
|
+
const { done, value } = await reader.read();
|
|
375
|
+
if (done) break;
|
|
376
|
+
buffer += decoder.decode(value, { stream: true });
|
|
377
|
+
|
|
378
|
+
let separatorIndex = buffer.indexOf("\n\n");
|
|
379
|
+
while (separatorIndex !== -1) {
|
|
380
|
+
const rawEvent = buffer.slice(0, separatorIndex);
|
|
381
|
+
buffer = buffer.slice(separatorIndex + 2);
|
|
382
|
+
|
|
383
|
+
const dataLines = rawEvent
|
|
384
|
+
.split("\n")
|
|
385
|
+
.filter((line) => line.startsWith("data:"))
|
|
386
|
+
.map((line) => line.slice(5).trim());
|
|
387
|
+
|
|
388
|
+
if (dataLines.length > 0) {
|
|
389
|
+
handlers.onMessage(dataLines.join("\n"));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
separatorIndex = buffer.indexOf("\n\n");
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
} catch (err) {
|
|
396
|
+
if (!(err instanceof Error && err.name === "AbortError")) {
|
|
397
|
+
handlers.onError?.(
|
|
398
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
} finally {
|
|
402
|
+
handlers.onClose?.();
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
return attempt(false);
|
|
407
|
+
}
|
|
247
408
|
}
|