@homecloud-platform/sdk 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # @homecloud-platform/sdk (Node.js)
2
+
3
+ Part of the **homecloud-sdk** monorepo — Python at repo root; Node in `js/`.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @homecloud-platform/sdk
9
+ ```
10
+
11
+ From a local checkout (lab / layers):
12
+
13
+ ```json
14
+ {
15
+ "dependencies": {
16
+ "@homecloud-platform/sdk": "file:../../../homecloud-sdk/js"
17
+ }
18
+ }
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```js
24
+ const { HomeCloud } = require("@homecloud-platform/sdk");
25
+
26
+ exports.handler = async (event, context) => {
27
+ const client = HomeCloud.fromSts(context.sts.archive, {
28
+ accountId: context.account_id,
29
+ });
30
+ await client.so.putJson("my-bucket", "proof/ok.json", { ok: true });
31
+ return { ok: true };
32
+ };
33
+ ```
34
+
35
+ ## Unified release (Python + Node)
36
+
37
+ One tag publishes **both** registries with the **same** version:
38
+
39
+ ```bash
40
+ ./scripts/bump-version.sh 0.5.0
41
+ git add pyproject.toml js/package.json
42
+ git commit -m "Release 0.5.0"
43
+ git push origin HEAD
44
+ git tag v0.5.0
45
+ git push origin v0.5.0
46
+ ```
47
+
48
+ | Registry | Package | Trigger |
49
+ |----------|---------|---------|
50
+ | PyPI | `homecloud-sdk` | tag `v0.5.0` |
51
+ | npm | `@homecloud-platform/sdk` | same tag `v0.5.0` |
52
+
53
+ Workflows sync the version from the tag before publish (Trusted Publishing / OIDC).
54
+
55
+ Optional npm-only hotfix (no PyPI): `js-v0.5.1`.
56
+
57
+ ### One-time npm setup
58
+
59
+ 1. Org **homecloud-platform** on npm.
60
+ 2. First publish once locally: `cd js && npm publish --access public`
61
+ 3. Package → **Trusted Publisher** → GitHub Actions:
62
+ - Org `HomeCloudLab`, repo `homecloud-sdk`
63
+ - Workflow: `publish-npm.yml`
64
+ - Environment: `npm`
65
+ 4. GitHub → Environments → create **`npm`** (and **`pypi`** already for Python).
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@homecloud-platform/sdk",
3
+ "version": "0.4.9",
4
+ "description": "HomeCloud Functions SDK for Node.js (ADR-033 / ADR-025e)",
5
+ "main": "src/index.js",
6
+ "types": "src/index.d.ts",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/HomeCloudLab/homecloud-sdk.git",
17
+ "directory": "js"
18
+ },
19
+ "homepage": "https://github.com/HomeCloudLab/homecloud-sdk/tree/master/js",
20
+ "bugs": {
21
+ "url": "https://github.com/HomeCloudLab/homecloud-sdk/issues"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "registry": "https://registry.npmjs.org/"
26
+ },
27
+ "scripts": {
28
+ "test": "node --test ./test/signing.test.js"
29
+ }
30
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ export type StsEntry = {
2
+ access_key_id: string;
3
+ secret_access_key: string;
4
+ session_token?: string;
5
+ base_url?: string;
6
+ mail_base_url?: string;
7
+ resource_type?: string;
8
+ resource_name?: string;
9
+ };
10
+
11
+ export declare class HomeCloudError extends Error {
12
+ statusCode?: number;
13
+ detail?: unknown;
14
+ constructor(message: string, opts?: { statusCode?: number; detail?: unknown });
15
+ }
16
+
17
+ export declare class HomeCloud {
18
+ so: {
19
+ upload(bucketName: string, filePath: string, opts?: { key?: string }): Promise<unknown>;
20
+ putJson(bucketName: string, objectKey: string, value: unknown): Promise<unknown>;
21
+ };
22
+ mq: {
23
+ send(queueName: string, body: unknown, opts?: { headers?: Record<string, string> }): Promise<unknown>;
24
+ };
25
+ secrets: {
26
+ get(secretName: string): Promise<unknown>;
27
+ };
28
+ mail: {
29
+ listMessages(opts?: { mailbox?: string; limit?: number }): Promise<unknown>;
30
+ };
31
+ static fromSts(sts: StsEntry, opts?: { accountId?: string; apex?: string }): HomeCloud;
32
+ static fromFunctionContext(context: Record<string, unknown>, opts: { binding: string }): HomeCloud;
33
+ }
34
+
35
+ export declare const DEFAULT_APEX: string;
package/src/index.js ADDED
@@ -0,0 +1,274 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const { URL } = require("url");
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+
8
+ const DEFAULT_APEX = "holab.abrdns.com";
9
+
10
+ function soUrl(apex) {
11
+ return `https://so.${apex || DEFAULT_APEX}`;
12
+ }
13
+ function mqUrl(apex) {
14
+ return `https://mq.${apex || DEFAULT_APEX}`;
15
+ }
16
+ function secretsUrl(apex) {
17
+ return `https://secrets.${apex || DEFAULT_APEX}`;
18
+ }
19
+ function mailApiUrl(apex) {
20
+ return `https://mailapi.${apex || DEFAULT_APEX}`;
21
+ }
22
+
23
+ function buildStringToSign({ method, path: reqPath, timestamp, accountId }) {
24
+ return `${String(method).toUpperCase()}\n${reqPath}\n${timestamp}\n${accountId}`;
25
+ }
26
+
27
+ function signRequestHeaders({
28
+ accessKeyId,
29
+ secret,
30
+ method,
31
+ path: reqPath,
32
+ accountId,
33
+ sessionToken,
34
+ }) {
35
+ const ts = new Date();
36
+ const timestamp = ts.toISOString().replace(/\.\d{3}Z$/, "Z");
37
+ const stringToSign = buildStringToSign({
38
+ method,
39
+ path: reqPath,
40
+ timestamp,
41
+ accountId,
42
+ });
43
+ const signature = crypto.createHmac("sha256", secret).update(stringToSign).digest("hex");
44
+ const headers = {
45
+ "X-Homecloud-Access-Key-Id": accessKeyId,
46
+ "X-Homecloud-Date": timestamp,
47
+ "X-Homecloud-Signature": signature,
48
+ };
49
+ if (sessionToken) headers["X-Homecloud-Session-Token"] = sessionToken;
50
+ return headers;
51
+ }
52
+
53
+ class HomeCloudError extends Error {
54
+ constructor(message, { statusCode, detail } = {}) {
55
+ super(message);
56
+ this.name = "HomeCloudError";
57
+ this.statusCode = statusCode;
58
+ this.detail = detail;
59
+ }
60
+ }
61
+
62
+ class HomeCloud {
63
+ constructor({
64
+ accessKeyId,
65
+ secretAccessKey,
66
+ accountId,
67
+ apex = DEFAULT_APEX,
68
+ sessionToken,
69
+ dataPlaneBases,
70
+ }) {
71
+ this.accessKeyId = accessKeyId;
72
+ this.secretAccessKey = secretAccessKey;
73
+ this.accountId = accountId;
74
+ this.apex = apex || DEFAULT_APEX;
75
+ this.sessionToken = sessionToken || null;
76
+ this.dataPlaneBases = dataPlaneBases || {};
77
+ this.so = new SoAPI(this);
78
+ this.mq = new MqAPI(this);
79
+ this.secrets = new SecretsAPI(this);
80
+ this.mail = new MailAPI(this);
81
+ }
82
+
83
+ static fromSts(sts, { accountId, apex } = {}) {
84
+ const aid = accountId || process.env.HC_ACCOUNT_ID || "";
85
+ let base = String(sts.base_url || sts.mail_base_url || "").replace(/\/$/, "");
86
+ const resourceType = String(sts.resource_type || "").trim().toLowerCase();
87
+ let resolvedApex = apex || process.env.HC_APEX || DEFAULT_APEX;
88
+ const dataPlaneBases = {};
89
+ if (base) {
90
+ let host = "";
91
+ try {
92
+ host = new URL(base).hostname || "";
93
+ } catch (_) {
94
+ host = "";
95
+ }
96
+ if (resourceType === "mail") {
97
+ if (host.startsWith("console.") || base.includes("/api/v1")) {
98
+ if (host.startsWith("console.")) resolvedApex = resolvedApex || host.slice("console.".length);
99
+ dataPlaneBases.mail = mailApiUrl(resolvedApex).replace(/\/$/, "");
100
+ } else {
101
+ dataPlaneBases.mail = base;
102
+ if (host.startsWith("mailapi.")) resolvedApex = resolvedApex || host.slice("mailapi.".length);
103
+ }
104
+ } else if (["so", "mq", "secrets"].includes(resourceType)) {
105
+ dataPlaneBases[resourceType] = base;
106
+ const prefix = `${resourceType}.`;
107
+ if (host.startsWith(prefix)) resolvedApex = resolvedApex || host.slice(prefix.length);
108
+ }
109
+ } else if (resourceType === "mail") {
110
+ dataPlaneBases.mail = mailApiUrl(resolvedApex).replace(/\/$/, "");
111
+ }
112
+ return new HomeCloud({
113
+ accessKeyId: String(sts.access_key_id),
114
+ secretAccessKey: String(sts.secret_access_key),
115
+ accountId: aid,
116
+ apex: resolvedApex || DEFAULT_APEX,
117
+ sessionToken: sts.session_token ? String(sts.session_token) : null,
118
+ dataPlaneBases,
119
+ });
120
+ }
121
+
122
+ static fromFunctionContext(context, { binding }) {
123
+ const ctx = context || {};
124
+ let stsMap = Object.assign({}, ctx.sts || {});
125
+ if (!Object.keys(stsMap).length && process.env.HC_STS_JSON) {
126
+ try {
127
+ stsMap = JSON.parse(process.env.HC_STS_JSON);
128
+ } catch (_) {
129
+ stsMap = {};
130
+ }
131
+ }
132
+ const entry = stsMap[binding];
133
+ if (!entry || !entry.access_key_id) {
134
+ throw new HomeCloudError(`STS binding '${binding}' not found in context.sts`);
135
+ }
136
+ return HomeCloud.fromSts(entry, {
137
+ accountId: ctx.account_id || ctx.accountId || process.env.HC_ACCOUNT_ID,
138
+ });
139
+ }
140
+
141
+ baseUrl(service) {
142
+ if (this.dataPlaneBases[service]) return this.dataPlaneBases[service].replace(/\/$/, "");
143
+ if (service === "so") return soUrl(this.apex);
144
+ if (service === "mq") return mqUrl(this.apex);
145
+ if (service === "secrets") return secretsUrl(this.apex);
146
+ if (service === "mail") return mailApiUrl(this.apex);
147
+ throw new HomeCloudError(`Unknown data-plane service: ${service}`);
148
+ }
149
+
150
+ async dataPlaneRequest(service, method, reqPath, { json, formData, body, headers } = {}) {
151
+ const base = this.baseUrl(service);
152
+ const url = new URL(reqPath.startsWith("http") ? reqPath : `${base}${reqPath}`);
153
+ const signPath = url.pathname + (url.search || "");
154
+ const auth = signRequestHeaders({
155
+ accessKeyId: this.accessKeyId,
156
+ secret: this.secretAccessKey,
157
+ method,
158
+ path: url.pathname,
159
+ accountId: this.accountId,
160
+ sessionToken: this.sessionToken,
161
+ });
162
+ const init = {
163
+ method,
164
+ headers: { ...auth, ...(headers || {}) },
165
+ };
166
+ if (formData) {
167
+ init.body = formData;
168
+ } else if (json !== undefined) {
169
+ init.headers["Content-Type"] = "application/json";
170
+ init.body = JSON.stringify(json);
171
+ } else if (body !== undefined) {
172
+ init.body = body;
173
+ }
174
+ const res = await fetch(url, init);
175
+ const text = await res.text();
176
+ let data = null;
177
+ if (text) {
178
+ try {
179
+ data = JSON.parse(text);
180
+ } catch (_) {
181
+ data = { raw: text };
182
+ }
183
+ }
184
+ if (!res.ok) {
185
+ throw new HomeCloudError(
186
+ (data && (data.detail || data.message || data.error)) || `HTTP ${res.status}`,
187
+ { statusCode: res.status, detail: data }
188
+ );
189
+ }
190
+ return data;
191
+ }
192
+ }
193
+
194
+ class SoAPI {
195
+ constructor(client) {
196
+ this._c = client;
197
+ }
198
+
199
+ async upload(bucketName, filePath, { key } = {}) {
200
+ const objectKey = key || path.basename(filePath);
201
+ const accountId = this._c.accountId;
202
+ const uploadPath = `/${accountId}/${bucketName}/objects`;
203
+ const blob = fs.readFileSync(filePath);
204
+ const form = new FormData();
205
+ form.append("key", objectKey);
206
+ form.append("file", new Blob([blob]), path.basename(filePath));
207
+ return this._c.dataPlaneRequest("so", "POST", uploadPath, { formData: form });
208
+ }
209
+
210
+ async putJson(bucketName, objectKey, value) {
211
+ const tmp = path.join(
212
+ require("os").tmpdir(),
213
+ `hc-sdk-${Date.now()}-${Math.random().toString(16).slice(2)}.json`
214
+ );
215
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2), "utf8");
216
+ try {
217
+ return await this.upload(bucketName, tmp, { key: objectKey });
218
+ } finally {
219
+ try {
220
+ fs.unlinkSync(tmp);
221
+ } catch (_) {}
222
+ }
223
+ }
224
+ }
225
+
226
+ class MqAPI {
227
+ constructor(client) {
228
+ this._c = client;
229
+ }
230
+
231
+ async send(queueName, body, { headers } = {}) {
232
+ const accountId = this._c.accountId;
233
+ const reqPath = `/${accountId}/${queueName}/messages`;
234
+ const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
235
+ const payload = { body: bodyStr };
236
+ if (headers) payload.headers = headers;
237
+ return this._c.dataPlaneRequest("mq", "POST", reqPath, { json: payload });
238
+ }
239
+ }
240
+
241
+ class SecretsAPI {
242
+ constructor(client) {
243
+ this._c = client;
244
+ }
245
+
246
+ async get(secretName) {
247
+ const accountId = this._c.accountId;
248
+ const reqPath = `/${accountId}/secrets/${encodeURIComponent(secretName)}`;
249
+ return this._c.dataPlaneRequest("secrets", "GET", reqPath);
250
+ }
251
+ }
252
+
253
+ class MailAPI {
254
+ constructor(client) {
255
+ this._c = client;
256
+ }
257
+
258
+ async listMessages({ mailbox = "INBOX", limit = 20 } = {}) {
259
+ const accountId = this._c.accountId;
260
+ const reqPath = `/${accountId}/mailboxes/${encodeURIComponent(mailbox)}/messages?limit=${limit}`;
261
+ return this._c.dataPlaneRequest("mail", "GET", reqPath);
262
+ }
263
+ }
264
+
265
+ module.exports = {
266
+ HomeCloud,
267
+ HomeCloudError,
268
+ signRequestHeaders,
269
+ soUrl,
270
+ mqUrl,
271
+ secretsUrl,
272
+ mailApiUrl,
273
+ DEFAULT_APEX,
274
+ };