@astralbeam/sdk 0.2.0 → 0.3.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 +17 -5
- package/dist/server.d.ts +37 -19
- package/dist/server.js +60 -48
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,16 +40,28 @@ import { createAstralBeamTokenRoute } from "@astralbeam/sdk/server"
|
|
|
40
40
|
|
|
41
41
|
export const POST = createAstralBeamTokenRoute({
|
|
42
42
|
apiKey: () => process.env.ASTRALBEAM_API_KEY, // key_<organization>_<key>_abo_<secret>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
authenticate: (request) => getApplicationSession(request),
|
|
44
|
+
user: (session) => ({
|
|
45
|
+
id: session.user.id,
|
|
46
|
+
name: session.user.name,
|
|
47
|
+
metadata: { email: session.user.email },
|
|
48
|
+
}),
|
|
49
|
+
tenant: (session) => ({
|
|
50
|
+
id: session.tenant.id,
|
|
51
|
+
name: session.tenant.name,
|
|
52
|
+
metadata: { plan: session.tenant.plan },
|
|
53
|
+
}),
|
|
47
54
|
})
|
|
48
55
|
```
|
|
49
56
|
|
|
50
57
|
- Add one endpoint, `/api/astralbeam/token` by default, that authenticates your own session first.
|
|
51
58
|
- The factory owns the method check, the unconfigured 503, the unauthenticated 401, and `no-store`.
|
|
52
|
-
-
|
|
59
|
+
- Authenticate once, then derive `user` and `tenant` separately from that same application session.
|
|
60
|
+
- Derive `user` and `tenant` from trusted server-side state, never from anything the browser sent.
|
|
61
|
+
- Provide stable tenant-local `user.id` and stable `tenant.id` values; names are optional, and set `user.admin` only from trusted state.
|
|
62
|
+
- Put custom tenant and tenant-user fields in their respective `metadata` JSON objects; never include secrets.
|
|
63
|
+
- SDK fields use camelCase; AstralBeam-owned JWT claims use snake_case, while `metadata` keys are preserved verbatim.
|
|
64
|
+
- Tokens use the API key's organization slug as issuer and the platform audience `astralbeam`; AstralBeam does not require or interpret `sub`.
|
|
53
65
|
- Tokens are signed, not encrypted: never put a secret in them.
|
|
54
66
|
- Lifetimes are 60–600 seconds; the SDK renews in memory before expiry.
|
|
55
67
|
|
package/dist/server.d.ts
CHANGED
|
@@ -1,39 +1,57 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
|
|
1
3
|
//#region src/server/index.d.ts
|
|
2
|
-
declare const
|
|
3
|
-
declare const
|
|
4
|
-
declare const
|
|
5
|
-
declare const ASTRALBEAM_CHAT_TOKEN_VERSION = 2;
|
|
4
|
+
declare const ASTRALBEAM_TOKEN_AUDIENCE = "astralbeam";
|
|
5
|
+
declare const ASTRALBEAM_CHAT_TOKEN_TYPE = "astralbeam+jwt";
|
|
6
|
+
declare const ASTRALBEAM_CHAT_TOKEN_VERSION = 4;
|
|
6
7
|
declare const ASTRALBEAM_CHAT_TOKEN_LIFETIME_SECONDS = 300;
|
|
7
8
|
declare const ASTRALBEAM_CHAT_TOKEN_MAX_LIFETIME_SECONDS = 600;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
readonly
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
declare const TenantSchema: Schema.Struct<{
|
|
10
|
+
readonly id: Schema.String;
|
|
11
|
+
readonly name: Schema.optional<Schema.String>;
|
|
12
|
+
readonly metadata: Schema.optional<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
|
|
13
|
+
}>;
|
|
14
|
+
declare const TenantUserSchema: Schema.Struct<{
|
|
15
|
+
readonly id: Schema.String;
|
|
16
|
+
readonly name: Schema.optional<Schema.String>;
|
|
17
|
+
readonly admin: Schema.optional<Schema.Boolean>;
|
|
18
|
+
readonly metadata: Schema.optional<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
|
|
19
|
+
}>;
|
|
20
|
+
/** Tenant identity from the Organization's application, including JSON metadata. */
|
|
21
|
+
type Tenant = typeof TenantSchema.Type;
|
|
22
|
+
/** User of an Organization's Tenant who interacts with AstralBeam. */
|
|
23
|
+
type TenantUser = typeof TenantUserSchema.Type;
|
|
24
|
+
interface CreateAstralBeamChatTokenOptions<TTenantUser extends TenantUser = TenantUser, TTenant extends Tenant = Tenant> {
|
|
13
25
|
readonly apiKey: string;
|
|
14
|
-
readonly
|
|
26
|
+
readonly user: TTenantUser;
|
|
27
|
+
readonly tenant: TTenant;
|
|
15
28
|
readonly expiresInSeconds?: number | undefined;
|
|
16
29
|
}
|
|
17
|
-
interface CreateAstralBeamTokenRouteOptions<TTenantUser extends TenantUser = TenantUser> {
|
|
30
|
+
interface CreateAstralBeamTokenRouteOptions<TSession extends object = object, TTenantUser extends TenantUser = TenantUser, TTenant extends Tenant = Tenant> {
|
|
18
31
|
/** The full API key, or a thunk read per request; missing or empty answers 503. */
|
|
19
32
|
readonly apiKey: string | undefined | (() => string | undefined);
|
|
20
33
|
/**
|
|
21
|
-
* Authenticates the request against the application's own session
|
|
22
|
-
*
|
|
34
|
+
* Authenticates the request against the application's own session. Returning nothing, or
|
|
35
|
+
* throwing, answers 401.
|
|
23
36
|
*/
|
|
24
|
-
readonly
|
|
37
|
+
readonly authenticate: (request: Request) => TSession | null | undefined | Promise<TSession | null | undefined>;
|
|
38
|
+
/** Maps the authenticated session to the tenant user minted into the token. */
|
|
39
|
+
readonly user: (session: TSession) => TTenantUser;
|
|
40
|
+
/** Maps the same authenticated session to the tenant minted into the token. */
|
|
41
|
+
readonly tenant: (session: TSession) => TTenant;
|
|
25
42
|
readonly expiresInSeconds?: number | undefined;
|
|
26
43
|
}
|
|
27
44
|
/**
|
|
28
45
|
* Builds the fetch-standard `POST` handler for an application's token endpoint, owning the
|
|
29
46
|
* method check, the unconfigured-key 503, the unauthenticated 401, and the `no-store` header.
|
|
30
47
|
*/
|
|
31
|
-
declare function createAstralBeamTokenRoute<TTenantUser extends TenantUser = TenantUser>(options: CreateAstralBeamTokenRouteOptions<TTenantUser>): (request: Request) => Promise<Response>;
|
|
48
|
+
declare function createAstralBeamTokenRoute<TSession extends object, TTenantUser extends TenantUser = TenantUser, TTenant extends Tenant = Tenant>(options: CreateAstralBeamTokenRouteOptions<TSession, TTenantUser, TTenant>): (request: Request) => Promise<Response>;
|
|
32
49
|
/** Creates the short-lived bearer token returned by an application's server auth endpoint. */
|
|
33
|
-
declare function createAstralBeamChatToken<TTenantUser extends TenantUser = TenantUser>({
|
|
50
|
+
declare function createAstralBeamChatToken<TTenantUser extends TenantUser = TenantUser, TTenant extends Tenant = Tenant>({
|
|
34
51
|
apiKey,
|
|
35
|
-
|
|
52
|
+
user,
|
|
53
|
+
tenant,
|
|
36
54
|
expiresInSeconds
|
|
37
|
-
}: CreateAstralBeamChatTokenOptions<TTenantUser>): Promise<string>;
|
|
55
|
+
}: CreateAstralBeamChatTokenOptions<TTenantUser, TTenant>): Promise<string>;
|
|
38
56
|
//#endregion
|
|
39
|
-
export {
|
|
57
|
+
export { ASTRALBEAM_CHAT_TOKEN_LIFETIME_SECONDS, ASTRALBEAM_CHAT_TOKEN_MAX_LIFETIME_SECONDS, ASTRALBEAM_CHAT_TOKEN_TYPE, ASTRALBEAM_CHAT_TOKEN_VERSION, ASTRALBEAM_TOKEN_AUDIENCE, CreateAstralBeamChatTokenOptions, CreateAstralBeamTokenRouteOptions, Tenant, TenantSchema, TenantUser, TenantUserSchema, createAstralBeamChatToken, createAstralBeamTokenRoute };
|
package/dist/server.js
CHANGED
|
@@ -638,54 +638,64 @@ var SignJWT = class {
|
|
|
638
638
|
};
|
|
639
639
|
//#endregion
|
|
640
640
|
//#region src/server/index.ts
|
|
641
|
-
const
|
|
642
|
-
const
|
|
643
|
-
const
|
|
644
|
-
const ASTRALBEAM_CHAT_TOKEN_VERSION = 2;
|
|
641
|
+
const ASTRALBEAM_TOKEN_AUDIENCE = "astralbeam";
|
|
642
|
+
const ASTRALBEAM_CHAT_TOKEN_TYPE = "astralbeam+jwt";
|
|
643
|
+
const ASTRALBEAM_CHAT_TOKEN_VERSION = 4;
|
|
645
644
|
const ASTRALBEAM_CHAT_TOKEN_LIFETIME_SECONDS = 300;
|
|
646
645
|
const ASTRALBEAM_CHAT_TOKEN_MAX_LIFETIME_SECONDS = 600;
|
|
647
|
-
const API_KEY_ID_PATTERN = /^key_[0-9a-z]{1,63}_([0-9a-z]{1,63})$/;
|
|
648
|
-
const API_KEY_SECRET_PATTERN = /^abo_[A-Za-z]{64}$/;
|
|
649
646
|
const CHAT_TOKEN_MAX_BYTES = 16384;
|
|
650
|
-
const
|
|
651
|
-
const TENANT_USER_MAX_DEPTH = 10;
|
|
647
|
+
const IDENTITY_MAX_BYTES = 8192;
|
|
652
648
|
const textEncoder = new TextEncoder();
|
|
653
|
-
const
|
|
654
|
-
const
|
|
655
|
-
const
|
|
649
|
+
const SlugSchema = Schema.String.pipe(Schema.check(Schema.isPattern(/^[0-9a-z-]{1,63}$/)));
|
|
650
|
+
const ApiKeySecretSchema = Schema.String.pipe(Schema.check(Schema.isPattern(/^abo_[A-Za-z]{64}$/)));
|
|
651
|
+
const ApiKeySchema = Schema.TemplateLiteral([
|
|
652
|
+
"key_",
|
|
653
|
+
SlugSchema,
|
|
654
|
+
"_",
|
|
655
|
+
SlugSchema,
|
|
656
|
+
"_",
|
|
657
|
+
ApiKeySecretSchema
|
|
658
|
+
]);
|
|
659
|
+
const isApiKey = Schema.is(ApiKeySchema);
|
|
660
|
+
const MetadataSchema = Schema.JsonObject.annotate({ message: "metadata must be a JSON object" });
|
|
661
|
+
const TenantExternalIdSchema = Schema.String.pipe(Schema.check(Schema.makeFilter((value) => value.length >= 1 && value.length <= 255, { message: "tenant.id must be a 1-255 character string" })));
|
|
662
|
+
const TenantUserExternalIdSchema = Schema.String.pipe(Schema.check(Schema.makeFilter((value) => value.length >= 1 && value.length <= 255, { message: "user.id must be a 1-255 character string" })));
|
|
663
|
+
const TenantSchema = Schema.Struct({
|
|
664
|
+
id: TenantExternalIdSchema,
|
|
665
|
+
name: Schema.optional(Schema.String),
|
|
666
|
+
metadata: Schema.optional(MetadataSchema)
|
|
667
|
+
});
|
|
668
|
+
const TenantUserSchema = Schema.Struct({
|
|
669
|
+
id: TenantUserExternalIdSchema,
|
|
670
|
+
name: Schema.optional(Schema.String),
|
|
671
|
+
admin: Schema.optional(Schema.Boolean),
|
|
672
|
+
metadata: Schema.optional(MetadataSchema)
|
|
673
|
+
});
|
|
674
|
+
const IdentitySchema = Schema.Struct({
|
|
675
|
+
user: TenantUserSchema,
|
|
676
|
+
tenant: TenantSchema
|
|
677
|
+
}).pipe(Schema.check(Schema.makeFilter((value) => textEncoder.encode(JSON.stringify(value)).byteLength <= IDENTITY_MAX_BYTES, { message: `user and tenant must not exceed ${IDENTITY_MAX_BYTES} bytes` })));
|
|
678
|
+
const decodeIdentity = Schema.decodeUnknownSync(IdentitySchema, {
|
|
656
679
|
errors: "all",
|
|
657
680
|
onExcessProperty: "error",
|
|
658
681
|
reportInput: false
|
|
659
682
|
});
|
|
660
683
|
function parseApiKey(apiKey) {
|
|
684
|
+
if (!isApiKey(apiKey)) throw new Error("apiKey must match key_<organization>_<key>_abo_<secret>");
|
|
661
685
|
const separator = apiKey.lastIndexOf("_abo_");
|
|
662
|
-
const
|
|
663
|
-
const
|
|
664
|
-
if (!API_KEY_ID_PATTERN.test(id) || !API_KEY_SECRET_PATTERN.test(secret)) throw new Error("apiKey must match key_<organization>_<key>_abo_<secret>");
|
|
686
|
+
const keyId = apiKey.slice(0, separator);
|
|
687
|
+
const keySecret = apiKey.slice(separator + 1);
|
|
665
688
|
return {
|
|
666
|
-
|
|
667
|
-
|
|
689
|
+
keyId,
|
|
690
|
+
organizationSlug: keyId.slice(4, keyId.indexOf("_", 4)),
|
|
691
|
+
keySecret
|
|
668
692
|
};
|
|
669
693
|
}
|
|
670
|
-
function
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
}
|
|
675
|
-
while (stack.length > 0) {
|
|
676
|
-
const current = stack.pop();
|
|
677
|
-
if (current.depth > maximumDepth) return true;
|
|
678
|
-
if (typeof current.value !== "object" || current.value === null) continue;
|
|
679
|
-
const children = Array.isArray(current.value) ? current.value : Object.values(current.value);
|
|
680
|
-
for (const child of children) stack.push({
|
|
681
|
-
value: child,
|
|
682
|
-
depth: current.depth + 1
|
|
683
|
-
});
|
|
684
|
-
}
|
|
685
|
-
return false;
|
|
686
|
-
}
|
|
687
|
-
function validatedTenantUser(value) {
|
|
688
|
-
return JSON.parse(JSON.stringify(decodeTenantUser(value)));
|
|
694
|
+
function validatedIdentity(user, tenant) {
|
|
695
|
+
return JSON.parse(JSON.stringify(decodeIdentity({
|
|
696
|
+
user,
|
|
697
|
+
tenant
|
|
698
|
+
})));
|
|
689
699
|
}
|
|
690
700
|
async function signingKey(secret) {
|
|
691
701
|
const digest = await crypto.subtle.digest("SHA-256", textEncoder.encode(secret));
|
|
@@ -706,17 +716,18 @@ function createAstralBeamTokenRoute(options) {
|
|
|
706
716
|
if (request.method !== "POST") return tokenRouteResponse({ error: "Use POST" }, 405);
|
|
707
717
|
const apiKey = typeof options.apiKey === "function" ? options.apiKey() : options.apiKey;
|
|
708
718
|
if (!apiKey) return tokenRouteResponse({ error: "The AstralBeam API key is not configured" }, 503);
|
|
709
|
-
let
|
|
719
|
+
let session;
|
|
710
720
|
try {
|
|
711
|
-
|
|
721
|
+
session = await options.authenticate(request);
|
|
712
722
|
} catch {
|
|
713
|
-
|
|
723
|
+
session = void 0;
|
|
714
724
|
}
|
|
715
|
-
if (!
|
|
725
|
+
if (!session) return tokenRouteResponse({ error: "The session could not be verified" }, 401);
|
|
716
726
|
try {
|
|
717
727
|
return tokenRouteResponse({ token: await createAstralBeamChatToken({
|
|
718
728
|
apiKey,
|
|
719
|
-
|
|
729
|
+
user: options.user(session),
|
|
730
|
+
tenant: options.tenant(session),
|
|
720
731
|
...options.expiresInSeconds === void 0 ? {} : { expiresInSeconds: options.expiresInSeconds }
|
|
721
732
|
}) }, 200);
|
|
722
733
|
} catch {
|
|
@@ -725,21 +736,22 @@ function createAstralBeamTokenRoute(options) {
|
|
|
725
736
|
};
|
|
726
737
|
}
|
|
727
738
|
/** Creates the short-lived bearer token returned by an application's server auth endpoint. */
|
|
728
|
-
async function createAstralBeamChatToken({ apiKey,
|
|
739
|
+
async function createAstralBeamChatToken({ apiKey, user, tenant, expiresInSeconds = 300 }) {
|
|
729
740
|
if (!Number.isInteger(expiresInSeconds) || expiresInSeconds < 60 || expiresInSeconds > 600) throw new Error("AstralBeam chat tokens must live for 60-600 seconds");
|
|
730
|
-
const {
|
|
731
|
-
const identity =
|
|
741
|
+
const { keyId, organizationSlug, keySecret } = parseApiKey(apiKey);
|
|
742
|
+
const identity = validatedIdentity(user, tenant);
|
|
732
743
|
const now = Math.floor(Date.now() / 1e3);
|
|
733
744
|
const token = await new SignJWT({
|
|
734
|
-
ver:
|
|
735
|
-
|
|
745
|
+
ver: 4,
|
|
746
|
+
user: identity.user,
|
|
747
|
+
tenant: identity.tenant
|
|
736
748
|
}).setProtectedHeader({
|
|
737
749
|
alg: "HS256",
|
|
738
750
|
typ: ASTRALBEAM_CHAT_TOKEN_TYPE,
|
|
739
|
-
kid:
|
|
740
|
-
}).setIssuer(
|
|
751
|
+
kid: keyId
|
|
752
|
+
}).setIssuer(organizationSlug).setAudience(ASTRALBEAM_TOKEN_AUDIENCE).setIssuedAt(now).setExpirationTime(now + expiresInSeconds).sign(await signingKey(keySecret));
|
|
741
753
|
if (textEncoder.encode(token).byteLength > CHAT_TOKEN_MAX_BYTES) throw new Error(`AstralBeam chat tokens must not exceed ${CHAT_TOKEN_MAX_BYTES} bytes`);
|
|
742
754
|
return token;
|
|
743
755
|
}
|
|
744
756
|
//#endregion
|
|
745
|
-
export {
|
|
757
|
+
export { ASTRALBEAM_CHAT_TOKEN_LIFETIME_SECONDS, ASTRALBEAM_CHAT_TOKEN_MAX_LIFETIME_SECONDS, ASTRALBEAM_CHAT_TOKEN_TYPE, ASTRALBEAM_CHAT_TOKEN_VERSION, ASTRALBEAM_TOKEN_AUDIENCE, TenantSchema, TenantUserSchema, createAstralBeamChatToken, createAstralBeamTokenRoute };
|