@hosty-sdk/app 0.5.0 → 0.6.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 +12 -0
- package/dist/delegated.d.ts +23 -0
- package/dist/delegated.js +54 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +4 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -46,6 +46,18 @@ await setAppSecret("trakt.connection.1.tokens", refreshed, config);
|
|
|
46
46
|
Reads are served from a write-through cache, namespaced by Core origin and app id; pass
|
|
47
47
|
`{ refresh: true }` to force a live read.
|
|
48
48
|
|
|
49
|
+
Delegated tokens — the credential a browser client (Shell) presents when calling a system
|
|
50
|
+
app's API directly. Core signs them (ECDSA P-256, 5-minute TTL) and injects the verification
|
|
51
|
+
key as `HOSTY_DELEGATED_TOKEN_PUBLIC_KEY`, so validation is fully local — no Core round-trip:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { validateDelegatedToken } from "@hosty-sdk/app/server";
|
|
55
|
+
|
|
56
|
+
// null for anything invalid (bad signature, wrong audience, expired) — treat like a missing token.
|
|
57
|
+
const claims = validateDelegatedToken(bearerToken);
|
|
58
|
+
if (claims?.role !== "host.admin") { /* 401/403 */ }
|
|
59
|
+
```
|
|
60
|
+
|
|
49
61
|
The design contract lives in the Hosty repository:
|
|
50
62
|
[`docs/ideas/hosty-app-sdk.md`](https://github.com/alex-de-haas/docker-host/blob/main/docs/ideas/hosty-app-sdk.md).
|
|
51
63
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface DelegatedTokenClaims {
|
|
2
|
+
/** Acting Host user id. */
|
|
3
|
+
sub: string;
|
|
4
|
+
/** The actor's Host role at issuance (e.g. "host.admin"); gate admin surfaces on it. */
|
|
5
|
+
role: string;
|
|
6
|
+
/** Audience app id — must equal this app's own id. */
|
|
7
|
+
aud: string;
|
|
8
|
+
iat: number;
|
|
9
|
+
exp: number;
|
|
10
|
+
jti: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ValidateDelegatedTokenOptions {
|
|
13
|
+
/** Audience to require; defaults to HOSTY_APP_ID. Validation fails when neither is set. */
|
|
14
|
+
appId?: string;
|
|
15
|
+
/** Base64 SPKI verification key; defaults to HOSTY_DELEGATED_TOKEN_PUBLIC_KEY. */
|
|
16
|
+
publicKey?: string;
|
|
17
|
+
/** Clock override for tests, ms since epoch. */
|
|
18
|
+
nowMs?: number;
|
|
19
|
+
}
|
|
20
|
+
/** Validates a delegated token locally and returns its claims, or null for anything invalid:
|
|
21
|
+
* bad format, unknown key, wrong audience, expiry, or signature mismatch. Deliberately never
|
|
22
|
+
* throws — a route treats null exactly like a missing token (401). */
|
|
23
|
+
export declare function validateDelegatedToken(token: string, options?: ValidateDelegatedTokenOptions): DelegatedTokenClaims | null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Delegated-token validation, in its own entry so plain Node services (not just Next apps) can
|
|
2
|
+
// import it: the server slice pulls in "server-only", which throws outside a React server bundle.
|
|
3
|
+
import { createPublicKey, verify as verifySignature } from "node:crypto";
|
|
4
|
+
const DELEGATED_TOKEN_PREFIX = "hosty_delegated";
|
|
5
|
+
const DELEGATED_TOKEN_VERSION = "1";
|
|
6
|
+
/** Validates a delegated token locally and returns its claims, or null for anything invalid:
|
|
7
|
+
* bad format, unknown key, wrong audience, expiry, or signature mismatch. Deliberately never
|
|
8
|
+
* throws — a route treats null exactly like a missing token (401). */
|
|
9
|
+
export function validateDelegatedToken(token, options = {}) {
|
|
10
|
+
const publicKeyBase64 = options.publicKey?.trim() || process.env.HOSTY_DELEGATED_TOKEN_PUBLIC_KEY?.trim();
|
|
11
|
+
const expectedAppId = options.appId?.trim() || process.env.HOSTY_APP_ID?.trim();
|
|
12
|
+
if (!publicKeyBase64 || !expectedAppId) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const parts = token.split(".");
|
|
16
|
+
const [prefix, version, payloadPart, signaturePart] = parts;
|
|
17
|
+
if (parts.length !== 4 ||
|
|
18
|
+
prefix !== DELEGATED_TOKEN_PREFIX ||
|
|
19
|
+
version !== DELEGATED_TOKEN_VERSION ||
|
|
20
|
+
payloadPart === undefined ||
|
|
21
|
+
signaturePart === undefined) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const signingInput = `${prefix}.${version}.${payloadPart}`;
|
|
26
|
+
const key = createPublicKey({
|
|
27
|
+
key: Buffer.from(publicKeyBase64, "base64"),
|
|
28
|
+
format: "der",
|
|
29
|
+
type: "spki",
|
|
30
|
+
});
|
|
31
|
+
const signatureValid = verifySignature("sha256", Buffer.from(signingInput, "utf8"), { key, dsaEncoding: "ieee-p1363" }, Buffer.from(signaturePart, "base64url"));
|
|
32
|
+
if (!signatureValid) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const claims = JSON.parse(Buffer.from(payloadPart, "base64url").toString("utf8"));
|
|
36
|
+
if (typeof claims.sub !== "string" ||
|
|
37
|
+
typeof claims.role !== "string" ||
|
|
38
|
+
typeof claims.aud !== "string" ||
|
|
39
|
+
typeof claims.exp !== "number") {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (claims.aud !== expectedAppId) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const nowSeconds = Math.floor((options.nowMs ?? Date.now()) / 1000);
|
|
46
|
+
if (claims.exp <= nowSeconds) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return claims;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -135,3 +135,4 @@ export declare function deleteAppSecret(key: string, config: HostyAppConfig): Pr
|
|
|
135
135
|
/** Lists the keys this app has stored. Always a live read — Core never returns values here, and
|
|
136
136
|
* the cache only knows the keys this process has touched. */
|
|
137
137
|
export declare function listAppSecretKeys(config: HostyAppConfig): Promise<string[]>;
|
|
138
|
+
export { validateDelegatedToken, type DelegatedTokenClaims, type ValidateDelegatedTokenOptions, } from "./delegated.js";
|
package/dist/server.js
CHANGED
|
@@ -551,3 +551,7 @@ async function readJsonOrThrow(response) {
|
|
|
551
551
|
throw new HostySecretsError("Core returned an unreadable app secrets response.", response.status, "core_response_invalid");
|
|
552
552
|
}
|
|
553
553
|
}
|
|
554
|
+
// Delegated tokens live in their own entry ("./delegated") because that module must stay
|
|
555
|
+
// importable from plain Node services (the ai-gateway system app) — this file's "server-only"
|
|
556
|
+
// import would throw there. Re-exported here so Next apps keep one import surface.
|
|
557
|
+
export { validateDelegatedToken, } from "./delegated.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hosty-sdk/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Hosty app auth: session state machine, recovery bridge, Core revalidation, and the embedder responder.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"types": "./dist/server.d.ts",
|
|
14
14
|
"default": "./dist/server.js"
|
|
15
15
|
},
|
|
16
|
+
"./delegated": {
|
|
17
|
+
"types": "./dist/delegated.d.ts",
|
|
18
|
+
"default": "./dist/delegated.js"
|
|
19
|
+
},
|
|
16
20
|
"./react": {
|
|
17
21
|
"types": "./dist/react.d.ts",
|
|
18
22
|
"default": "./dist/react.js"
|