@~lyre/auth 0.0.9 → 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 CHANGED
@@ -5,7 +5,7 @@ and identity handling (HMAC-signed cookies, the accounts login → callback →
5
5
  logout exchange, tenant resolution), plus an optional turnkey **SvelteKit**
6
6
  adapter that wires it all up in a few lines.
7
7
 
8
- Ships as raw TypeScript source.
8
+ Ships built ESM output from `dist/`, with type declarations.
9
9
 
10
10
  ## Install
11
11
 
@@ -53,6 +53,43 @@ export const handle = createAuthHandle({
53
53
  `@sveltejs/kit` is an **optional peer dependency** — only required if you import
54
54
  the `/sveltekit` entry.
55
55
 
56
+ ### `@~lyre/auth/nest` — Nest / Fastify adapter
57
+
58
+ For services on Nest + Fastify. Adds **no dependencies** — nothing here imports
59
+ `@nestjs/*` or `fastify`; you supply the guard class and DI wiring, the SDK
60
+ supplies the Accounts logic. Works from a plain Fastify app too: any request
61
+ whose `headers` is a plain object satisfies the input type.
62
+
63
+ ```ts
64
+ import { createServiceKeyResolver, createUserSessionResolver, hasScopes }
65
+ from '@~lyre/auth/nest';
66
+
67
+ const keys = createServiceKeyResolver({ config: { baseUrl: ACCOUNTS_BASE_URL } });
68
+ const sessions = createUserSessionResolver({ config: { baseUrl: ACCOUNTS_BASE_URL } });
69
+
70
+ // In a Nest guard (req is the Fastify request):
71
+ const key = await keys.resolve(req);
72
+ if (key.status === 'invalid') throw new UnauthorizedException(); // present but bad
73
+ if (key.status === 'ok') {
74
+ if (!hasScopes(key.context, ['my-app:things:read'])) throw new ForbiddenException();
75
+ // key.context.accessibleApps tells you which apps this key may act on
76
+ }
77
+
78
+ // A user session bearer resolves to who + which app, from the token alone:
79
+ const user = await sessions.resolve(req);
80
+ // → { userId, sessionId, appId, tenantId, email, name } | null
81
+ ```
82
+
83
+ Semantics match `createServiceKeyHandle`: **absent key → `absent`** (fall through to
84
+ other auth), **present-but-invalid → `invalid`** (reject rather than silently
85
+ continue). Introspection is cached by `sha256` of the credential — negative
86
+ results too, so a bad key can't hammer Accounts. Neither resolver throws: a
87
+ transport failure reads as "not authenticated", so a flaky Accounts cannot 500
88
+ the consuming service.
89
+
90
+ `resolve()` needs `GET /api/auth/me` to return `session.appId` (Accounts ≥ the
91
+ release that added it); older deployments yield `appId: null`.
92
+
56
93
  ## Publishing
57
94
 
58
95
  See [PUBLISHING.md](PUBLISHING.md). Published to npm via GitHub Actions Trusted
@@ -0,0 +1,146 @@
1
+ interface AccountsAdminOptions {
2
+ /** Accounts base URL, e.g. `https://accounts.example.com`. */
3
+ baseUrl: string;
4
+ /** A user session id (bearer) for session-authed endpoints (invites, profile update, user list). */
5
+ sessionToken?: string;
6
+ /** A machine service key (`sk_…`) for `x-api-key`-authed reads (group members). */
7
+ serviceKey?: string;
8
+ /** Override the fetch implementation (defaults to the global). */
9
+ fetchImpl?: typeof fetch;
10
+ }
11
+ interface AccountsUser {
12
+ id: string;
13
+ email: string;
14
+ name?: string | null;
15
+ firstName?: string | null;
16
+ lastName?: string | null;
17
+ emailVerified?: boolean;
18
+ /** The user's avatar URL (from their latest OAuth sign-in with a picture, or their upload). */
19
+ avatarUrl?: string | null;
20
+ profileData?: Record<string, string>;
21
+ userGroups?: Array<{
22
+ id: string;
23
+ slug?: string;
24
+ displayName?: string;
25
+ role?: string;
26
+ }>;
27
+ apps?: Array<{
28
+ id: string;
29
+ slug: string;
30
+ name?: string;
31
+ }>;
32
+ [key: string]: unknown;
33
+ }
34
+ interface ChangePasswordResult {
35
+ success: boolean;
36
+ mode?: 'set' | 'changed';
37
+ revokedCount?: number;
38
+ error?: string;
39
+ }
40
+ interface ListUsersQuery {
41
+ tenantId?: string;
42
+ appId?: string;
43
+ q?: string;
44
+ name?: string;
45
+ email?: string;
46
+ limit?: number;
47
+ offset?: number;
48
+ }
49
+ interface ListUsersResult {
50
+ users: AccountsUser[];
51
+ pagination?: {
52
+ total: number;
53
+ limit: number;
54
+ offset: number;
55
+ };
56
+ }
57
+ interface AppMember {
58
+ id: string;
59
+ email: string;
60
+ name?: string | null;
61
+ via?: 'member' | 'role';
62
+ }
63
+ interface InviteResult {
64
+ /** `added` = the email already had an Accounts user and was granted app access immediately;
65
+ * `invited` = a new invite was created and an accept link emailed. */
66
+ status: 'added' | 'invited';
67
+ [key: string]: unknown;
68
+ }
69
+ interface GroupMember {
70
+ id: string;
71
+ email: string;
72
+ name?: string | null;
73
+ firstName?: string | null;
74
+ lastName?: string | null;
75
+ role?: string | null;
76
+ }
77
+ /** Thrown on a non-2xx Accounts response so callers can surface the upstream message + status. */
78
+ declare class AccountsAdminError extends Error {
79
+ readonly status: number;
80
+ readonly body?: unknown | undefined;
81
+ constructor(status: number, message: string, body?: unknown | undefined);
82
+ }
83
+ declare class AccountsAdmin {
84
+ private readonly opts;
85
+ private readonly base;
86
+ private readonly f;
87
+ constructor(opts: AccountsAdminOptions);
88
+ /** List/search users in a tenant. Session-authed (caller must be privileged in the tenant). */
89
+ listUsers(query?: ListUsersQuery): Promise<ListUsersResult>;
90
+ /** One user's full detail (includes `profileData`, `userGroups`, `apps`). Session-authed. */
91
+ getUser(userId: string): Promise<AccountsUser>;
92
+ /** Update a user (self, or admin/owner/super-admin). `profileData` requires `appId`. Session-authed.
93
+ * Password is NOT set here — use {@link changePassword} (Accounts verifies the current password). */
94
+ updateUser(userId: string, input: {
95
+ name?: string;
96
+ email?: string;
97
+ appId?: string;
98
+ profileData?: Record<string, string>;
99
+ }): Promise<AccountsUser>;
100
+ /** Change the signed-in user's password. Session-authed; Accounts verifies `currentPassword` and,
101
+ * with `revokeOtherSessions`, logs the user out everywhere else. The target is the session's own
102
+ * user — no id is passed. */
103
+ changePassword(input: {
104
+ currentPassword: string;
105
+ newPassword: string;
106
+ confirmPassword?: string;
107
+ revokeOtherSessions?: boolean;
108
+ }): Promise<ChangePasswordResult>;
109
+ /** Invite an email to an app: adds an existing Accounts user immediately, else emails an accept
110
+ * link. Session-authed (caller must have access to the app). */
111
+ inviteToApp(appSlug: string, input: {
112
+ email: string;
113
+ }): Promise<InviteResult>;
114
+ /** Everyone with access to an app. Session-authed. */
115
+ listAppUsers(appSlug: string): Promise<AppMember[]>;
116
+ /** Revoke a user's access to an app. Session-authed. */
117
+ removeAppUser(appSlug: string, userId: string): Promise<void>;
118
+ /** Members of a user_group (the workspace's agents). Service-key authed (`stats:read`). */
119
+ listGroupMembers(groupId: string, opts?: {
120
+ appId?: string;
121
+ }): Promise<GroupMember[]>;
122
+ /** Read a user's app-scoped `profileData` (the store for per-app preferences). Session-authed. */
123
+ getAppProfile(userId: string): Promise<Record<string, string>>;
124
+ /** Write app-scoped `profileData` fields for a user (e.g. availability, soundEnabled). All values
125
+ * are stored as strings by Accounts; booleans are serialised. Session-authed. */
126
+ setAppProfile(userId: string, appId: string, profile: Record<string, string | number | boolean>): Promise<AccountsUser>;
127
+ /** Upload a user's avatar image. The bytes are stored by Accounts and a public URL is returned +
128
+ * persisted (source = 'upload', which survives later OAuth sign-ins). Session-authed, self-only.
129
+ * `image` is the file bytes (Blob/Buffer/Uint8Array); `mimeType` + `filename` describe it. */
130
+ uploadAvatar(userId: string, image: Blob | ArrayBuffer | Uint8Array, opts?: {
131
+ mimeType?: string;
132
+ filename?: string;
133
+ }): Promise<{
134
+ avatarUrl: string;
135
+ }>;
136
+ /** Remove a user's uploaded avatar (clears it, or reverts to their latest OAuth picture if any).
137
+ * Session-authed, self-only. */
138
+ removeAvatar(userId: string): Promise<{
139
+ avatarUrl: string | null;
140
+ }>;
141
+ private request;
142
+ }
143
+ /** Convenience factory mirroring the SDK's other `create*` helpers. */
144
+ declare function createAccountsAdmin(opts: AccountsAdminOptions): AccountsAdmin;
145
+
146
+ export { AccountsAdmin, AccountsAdminError, type AccountsAdminOptions, type AccountsUser, type AppMember, type ChangePasswordResult, type GroupMember, type InviteResult, type ListUsersQuery, type ListUsersResult, createAccountsAdmin };
package/dist/admin.js ADDED
@@ -0,0 +1,193 @@
1
+ // src/admin.ts
2
+ var AccountsAdminError = class extends Error {
3
+ constructor(status, message, body) {
4
+ super(message);
5
+ this.status = status;
6
+ this.body = body;
7
+ this.name = "AccountsAdminError";
8
+ }
9
+ status;
10
+ body;
11
+ };
12
+ var AccountsAdmin = class {
13
+ constructor(opts) {
14
+ this.opts = opts;
15
+ const base = (opts.baseUrl ?? "").trim();
16
+ if (!base) throw new Error("AccountsAdmin: baseUrl is required.");
17
+ this.base = base.replace(/\/+$/, "");
18
+ this.f = opts.fetchImpl ?? fetch;
19
+ }
20
+ opts;
21
+ base;
22
+ f;
23
+ // ── Users ──────────────────────────────────────────────────────────────────
24
+ /** List/search users in a tenant. Session-authed (caller must be privileged in the tenant). */
25
+ async listUsers(query = {}) {
26
+ const qs = new URLSearchParams();
27
+ for (const [k, v] of Object.entries(query)) {
28
+ if (v !== void 0 && v !== null) qs.set(k, String(v));
29
+ }
30
+ const suffix = qs.toString() ? `?${qs.toString()}` : "";
31
+ const data = await this.request(
32
+ "GET",
33
+ `/api/auth/users${suffix}`,
34
+ { auth: "session" }
35
+ );
36
+ return { users: data.users ?? [], ...data.pagination ? { pagination: data.pagination } : {} };
37
+ }
38
+ /** One user's full detail (includes `profileData`, `userGroups`, `apps`). Session-authed. */
39
+ async getUser(userId) {
40
+ return this.request("GET", `/api/auth/users/${encodeURIComponent(userId)}`, {
41
+ auth: "session"
42
+ });
43
+ }
44
+ /** Update a user (self, or admin/owner/super-admin). `profileData` requires `appId`. Session-authed.
45
+ * Password is NOT set here — use {@link changePassword} (Accounts verifies the current password). */
46
+ async updateUser(userId, input) {
47
+ return this.request("PATCH", `/api/auth/users/${encodeURIComponent(userId)}`, {
48
+ auth: "session",
49
+ body: input
50
+ });
51
+ }
52
+ /** Change the signed-in user's password. Session-authed; Accounts verifies `currentPassword` and,
53
+ * with `revokeOtherSessions`, logs the user out everywhere else. The target is the session's own
54
+ * user — no id is passed. */
55
+ async changePassword(input) {
56
+ return this.request("POST", "/api/auth/change-password", {
57
+ auth: "session",
58
+ body: {
59
+ currentPassword: input.currentPassword,
60
+ newPassword: input.newPassword,
61
+ confirmPassword: input.confirmPassword ?? input.newPassword,
62
+ ...input.revokeOtherSessions !== void 0 ? { revokeOtherSessions: input.revokeOtherSessions } : {}
63
+ }
64
+ });
65
+ }
66
+ // ── App membership (invite / list / remove) ─────────────────────────────────
67
+ /** Invite an email to an app: adds an existing Accounts user immediately, else emails an accept
68
+ * link. Session-authed (caller must have access to the app). */
69
+ async inviteToApp(appSlug, input) {
70
+ return this.request("POST", `/api/auth/apps/${encodeURIComponent(appSlug)}/invites`, {
71
+ auth: "session",
72
+ body: input
73
+ });
74
+ }
75
+ /** Everyone with access to an app. Session-authed. */
76
+ async listAppUsers(appSlug) {
77
+ const data = await this.request(
78
+ "GET",
79
+ `/api/auth/apps/${encodeURIComponent(appSlug)}/users`,
80
+ { auth: "session" }
81
+ );
82
+ return data.users ?? [];
83
+ }
84
+ /** Revoke a user's access to an app. Session-authed. */
85
+ async removeAppUser(appSlug, userId) {
86
+ await this.request(
87
+ "DELETE",
88
+ `/api/auth/apps/${encodeURIComponent(appSlug)}/users?userId=${encodeURIComponent(userId)}`,
89
+ { auth: "session" }
90
+ );
91
+ }
92
+ // ── Groups (agent picker) ───────────────────────────────────────────────────
93
+ /** Members of a user_group (the workspace's agents). Service-key authed (`stats:read`). */
94
+ async listGroupMembers(groupId, opts = {}) {
95
+ const suffix = opts.appId ? `?appId=${encodeURIComponent(opts.appId)}` : "";
96
+ const data = await this.request(
97
+ "GET",
98
+ `/api/auth/user-groups/${encodeURIComponent(groupId)}/members${suffix}`,
99
+ { auth: "key" }
100
+ );
101
+ return data.members ?? [];
102
+ }
103
+ // ── App-scoped profile (availability / sound / any per-app preference) ───────
104
+ /** Read a user's app-scoped `profileData` (the store for per-app preferences). Session-authed. */
105
+ async getAppProfile(userId) {
106
+ const user = await this.getUser(userId);
107
+ return user.profileData ?? {};
108
+ }
109
+ /** Write app-scoped `profileData` fields for a user (e.g. availability, soundEnabled). All values
110
+ * are stored as strings by Accounts; booleans are serialised. Session-authed. */
111
+ async setAppProfile(userId, appId, profile) {
112
+ const profileData = {};
113
+ for (const [k, v] of Object.entries(profile)) profileData[k] = String(v);
114
+ return this.updateUser(userId, { appId, profileData });
115
+ }
116
+ // ── Avatar (uploaded image; social avatars are captured server-side on sign-in) ─
117
+ /** Upload a user's avatar image. The bytes are stored by Accounts and a public URL is returned +
118
+ * persisted (source = 'upload', which survives later OAuth sign-ins). Session-authed, self-only.
119
+ * `image` is the file bytes (Blob/Buffer/Uint8Array); `mimeType` + `filename` describe it. */
120
+ async uploadAvatar(userId, image, opts = {}) {
121
+ if (!this.opts.sessionToken) {
122
+ throw new Error("AccountsAdmin: a session token is required to upload an avatar.");
123
+ }
124
+ const mimeType = opts.mimeType ?? "application/octet-stream";
125
+ const blob = image instanceof Blob ? image : new Blob([image], { type: mimeType });
126
+ const form = new FormData();
127
+ form.append("avatar", blob, opts.filename ?? "avatar");
128
+ const res = await this.f(
129
+ new URL(`/api/auth/users/${encodeURIComponent(userId)}/avatar`, this.base),
130
+ {
131
+ method: "POST",
132
+ headers: { authorization: `Bearer ${this.opts.sessionToken}`, accept: "application/json" },
133
+ body: form
134
+ }
135
+ );
136
+ const text = await res.text();
137
+ const parsed = text ? JSON.parse(text) : {};
138
+ if (!res.ok) {
139
+ throw new AccountsAdminError(res.status, parsed?.error ?? "Avatar upload failed.", parsed);
140
+ }
141
+ return parsed;
142
+ }
143
+ /** Remove a user's uploaded avatar (clears it, or reverts to their latest OAuth picture if any).
144
+ * Session-authed, self-only. */
145
+ async removeAvatar(userId) {
146
+ return this.request(
147
+ "DELETE",
148
+ `/api/auth/users/${encodeURIComponent(userId)}/avatar`,
149
+ { auth: "session" }
150
+ );
151
+ }
152
+ // ── internals ───────────────────────────────────────────────────────────────
153
+ async request(method, path, opts) {
154
+ const headers = { accept: "application/json" };
155
+ if (opts.auth === "session") {
156
+ if (!this.opts.sessionToken) {
157
+ throw new Error(`AccountsAdmin: a session token is required for ${method} ${path}.`);
158
+ }
159
+ headers.authorization = `Bearer ${this.opts.sessionToken}`;
160
+ } else {
161
+ if (!this.opts.serviceKey) {
162
+ throw new Error(`AccountsAdmin: a service key is required for ${method} ${path}.`);
163
+ }
164
+ headers["x-api-key"] = this.opts.serviceKey;
165
+ }
166
+ let bodyStr;
167
+ if (opts.body !== void 0) {
168
+ bodyStr = JSON.stringify(opts.body);
169
+ headers["content-type"] = "application/json";
170
+ }
171
+ const res = await this.f(new URL(path, this.base), { method, headers, body: bodyStr });
172
+ const text = await res.text();
173
+ let parsed;
174
+ try {
175
+ parsed = text ? JSON.parse(text) : void 0;
176
+ } catch {
177
+ parsed = text;
178
+ }
179
+ if (!res.ok) {
180
+ const message = (parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : void 0) ?? `Accounts request failed with status ${res.status}.`;
181
+ throw new AccountsAdminError(res.status, message, parsed);
182
+ }
183
+ return parsed;
184
+ }
185
+ };
186
+ function createAccountsAdmin(opts) {
187
+ return new AccountsAdmin(opts);
188
+ }
189
+ export {
190
+ AccountsAdmin,
191
+ AccountsAdminError,
192
+ createAccountsAdmin
193
+ };
@@ -308,6 +308,11 @@ function verifyWebhookSignature(rawBody, signature, secret) {
308
308
  if (a.length !== b.length) return false;
309
309
  return timingSafeEqual(a, b);
310
310
  }
311
+ function normalizeAccountsWebhook(payload) {
312
+ const body = payload && typeof payload === "object" ? payload : {};
313
+ const type = String(body.event ?? body.type ?? "unknown");
314
+ return { ...body, type, data: body.data };
315
+ }
311
316
  function scopeSatisfied(granted, required) {
312
317
  if (!granted || granted.length === 0) return false;
313
318
  return granted.includes(required);
@@ -328,5 +333,6 @@ export {
328
333
  introspectServiceKey,
329
334
  logoutFromAccounts,
330
335
  verifyWebhookSignature,
336
+ normalizeAccountsWebhook,
331
337
  scopeSatisfied
332
338
  };
package/dist/index.d.ts CHANGED
@@ -174,8 +174,18 @@ declare function logoutFromAccounts(input: {
174
174
  declare function verifyWebhookSignature(rawBody: string, signature: string | null | undefined, secret: string): boolean;
175
175
  type AccountsWebhookEvent = {
176
176
  type: string;
177
+ data?: unknown;
177
178
  [key: string]: unknown;
178
179
  };
180
+ /**
181
+ * Normalize an Accounts webhook body into a stable `AccountsWebhookEvent`.
182
+ *
183
+ * The Accounts webhook worker sends `{ event, timestamp, data }` (the event NAME under `event`,
184
+ * the payload under `data`). Older/other senders may put the name under `type`. This collapses both
185
+ * so a consumer always reads `event.type` and `event.data`, and keeps the raw fields alongside.
186
+ * Framework-agnostic so both the SvelteKit handle and the Nest helper share one shape.
187
+ */
188
+ declare function normalizeAccountsWebhook(payload: unknown): AccountsWebhookEvent;
179
189
  declare function scopeSatisfied(granted: string[] | undefined, required: string): boolean;
180
190
 
181
- export { type AccountsClientConfig, type AccountsIdentity, type AccountsTokenResponse, type AccountsWebhookEvent, type AuthenticatedPrincipal, type AuthorizationState, type CookieOptions, type LocalUser, type PermissionSet, type PlatformSession, type ServiceKeyApp, type ServiceKeyContext, type SyncAccountsUser, type SyncAccountsUserResult, type TenantAccessContext, type TenantContext, type TenantMembership, beginAccountsLoginRedirect, clearPlatformSessionCookie, createAccountsClientConfig, createPlatformAuth, exchangeAuthorizationCode, handleAccountsCallback, identityPassthroughSync, introspectServiceKey, logoutFromAccounts, readPlatformSessionCookie, resolveActiveTenant, scopeSatisfied, serviceKeyFromRequest, syncAccountsUser, verifyWebhookSignature };
191
+ export { type AccountsClientConfig, type AccountsIdentity, type AccountsTokenResponse, type AccountsWebhookEvent, type AuthenticatedPrincipal, type AuthorizationState, type CookieOptions, type LocalUser, type PermissionSet, type PlatformSession, type ServiceKeyApp, type ServiceKeyContext, type SyncAccountsUser, type SyncAccountsUserResult, type TenantAccessContext, type TenantContext, type TenantMembership, beginAccountsLoginRedirect, clearPlatformSessionCookie, createAccountsClientConfig, createPlatformAuth, exchangeAuthorizationCode, handleAccountsCallback, identityPassthroughSync, introspectServiceKey, logoutFromAccounts, normalizeAccountsWebhook, readPlatformSessionCookie, resolveActiveTenant, scopeSatisfied, serviceKeyFromRequest, syncAccountsUser, verifyWebhookSignature };
package/dist/index.js CHANGED
@@ -8,13 +8,14 @@ import {
8
8
  identityPassthroughSync,
9
9
  introspectServiceKey,
10
10
  logoutFromAccounts,
11
+ normalizeAccountsWebhook,
11
12
  readPlatformSessionCookie,
12
13
  resolveActiveTenant,
13
14
  scopeSatisfied,
14
15
  serviceKeyFromRequest,
15
16
  syncAccountsUser,
16
17
  verifyWebhookSignature
17
- } from "./chunk-QIAL6IDV.js";
18
+ } from "./chunk-TQNQLX3C.js";
18
19
  export {
19
20
  beginAccountsLoginRedirect,
20
21
  clearPlatformSessionCookie,
@@ -25,6 +26,7 @@ export {
25
26
  identityPassthroughSync,
26
27
  introspectServiceKey,
27
28
  logoutFromAccounts,
29
+ normalizeAccountsWebhook,
28
30
  readPlatformSessionCookie,
29
31
  resolveActiveTenant,
30
32
  scopeSatisfied,
package/dist/nest.d.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { AccountsWebhookEvent, ServiceKeyContext, AccountsClientConfig } from './index.js';
2
+ export { normalizeAccountsWebhook } from './index.js';
3
+
4
+ /**
5
+ * The minimum a request must expose. Fastify's `request` satisfies this as-is
6
+ * (`headers` is a plain object), as does Node's IncomingMessage.
7
+ */
8
+ type HeaderCarrier = {
9
+ headers: Record<string, string | string[] | undefined>;
10
+ };
11
+ /**
12
+ * Adapt a header-bearing request to the web `Request` that the core helpers expect.
13
+ * Only headers are needed — `serviceKeyFromRequest` reads `x-api-key` / `authorization`.
14
+ */
15
+ declare function toWebRequest(carrier: HeaderCarrier): Request;
16
+ /** Extract an `sk_` service key from a Fastify/Node request, or null when absent. */
17
+ declare function serviceKeyFromHeaders(carrier: HeaderCarrier): string | null;
18
+ type ServiceKeyResolverOptions = {
19
+ config: Pick<AccountsClientConfig, 'baseUrl'>;
20
+ fetchImpl?: typeof fetch;
21
+ /** Introspection cache TTL in ms. Default 60_000; 0 disables caching. */
22
+ cacheTtlMs?: number;
23
+ };
24
+ type ServiceKeyResolution = {
25
+ status: 'absent';
26
+ } | {
27
+ status: 'invalid';
28
+ } | {
29
+ status: 'ok';
30
+ context: ServiceKeyContext;
31
+ };
32
+ declare function createServiceKeyResolver(opts: ServiceKeyResolverOptions): {
33
+ resolve: (carrier: HeaderCarrier) => Promise<ServiceKeyResolution>;
34
+ invalidate: (key: string) => void;
35
+ };
36
+ type UserSessionContext = {
37
+ userId: string;
38
+ email?: string;
39
+ name?: string;
40
+ sessionId: string;
41
+ /** The app the session was issued for. Null for sessions created outside an app context. */
42
+ appId: string | null;
43
+ tenantId: string | null;
44
+ expiresAt?: string;
45
+ };
46
+ type UserSessionResolverOptions = {
47
+ config: Pick<AccountsClientConfig, 'baseUrl'>;
48
+ fetchImpl?: typeof fetch;
49
+ /** Cache TTL in ms. Default 60_000; 0 disables caching. */
50
+ cacheTtlMs?: number;
51
+ };
52
+ /** A session bearer is any Authorization: Bearer token that is NOT an `sk_` service key. */
53
+ declare function sessionTokenFromHeaders(carrier: HeaderCarrier): string | null;
54
+ declare function createUserSessionResolver(opts: UserSessionResolverOptions): {
55
+ resolve: (carrier: HeaderCarrier) => Promise<UserSessionContext | null>;
56
+ invalidate: (token: string) => void;
57
+ };
58
+ /**
59
+ * Whether a resolved key grants every required scope. Uses the core's `scopeSatisfied`
60
+ * (flat exact match, no wildcards) so behaviour matches Accounts' own enforcement.
61
+ */
62
+ declare function hasScopes(ctx: ServiceKeyContext, required: readonly string[]): boolean;
63
+ /** Whether a resolved key may act on an app, matched by Accounts app id OR slug. */
64
+ declare function canAccessApp(ctx: ServiceKeyContext, appIdOrSlug: string): boolean;
65
+ type AccountsWebhookResult = {
66
+ ok: true;
67
+ event: AccountsWebhookEvent;
68
+ } | {
69
+ ok: false;
70
+ reason: 'bad_signature' | 'bad_json';
71
+ };
72
+ /** Header the Accounts webhook worker sends (HMAC-SHA256 hex of the raw body). */
73
+ declare const ACCOUNTS_WEBHOOK_SIGNATURE_HEADER = "x-webhook-signature";
74
+ /**
75
+ * Verify an Accounts webhook and parse it into a normalized `{ type, data, ... }` event.
76
+ *
77
+ * Pass the RAW request body (a Nest/Fastify controller must read the raw body, not a re-serialized
78
+ * object — the signature is over the exact bytes), the signature header value
79
+ * (`request.headers['x-webhook-signature']`), and the shared secret. Returns `{ok:false}` on a bad
80
+ * signature or unparseable JSON so the controller can 401/400; `{ok:true, event}` otherwise. The
81
+ * controller then reacts to `event.type` / `event.data` and returns 200 (so Accounts doesn't retry).
82
+ */
83
+ declare function verifyAndParseAccountsWebhook(rawBody: string, signatureHeaderValue: string | string[] | undefined, secret: string): AccountsWebhookResult;
84
+
85
+ export { ACCOUNTS_WEBHOOK_SIGNATURE_HEADER, AccountsClientConfig, AccountsWebhookEvent, type AccountsWebhookResult, type HeaderCarrier, ServiceKeyContext, type ServiceKeyResolution, type ServiceKeyResolverOptions, type UserSessionContext, type UserSessionResolverOptions, canAccessApp, createServiceKeyResolver, createUserSessionResolver, hasScopes, serviceKeyFromHeaders, sessionTokenFromHeaders, toWebRequest, verifyAndParseAccountsWebhook };
package/dist/nest.js ADDED
@@ -0,0 +1,134 @@
1
+ import {
2
+ introspectServiceKey,
3
+ normalizeAccountsWebhook,
4
+ scopeSatisfied,
5
+ serviceKeyFromRequest,
6
+ verifyWebhookSignature
7
+ } from "./chunk-TQNQLX3C.js";
8
+
9
+ // src/nest.ts
10
+ import { createHash } from "crypto";
11
+ function headerValue(carrier, name) {
12
+ const v = carrier.headers[name] ?? carrier.headers[name.toLowerCase()];
13
+ return Array.isArray(v) ? v[0] : v;
14
+ }
15
+ function toWebRequest(carrier) {
16
+ const headers = new Headers();
17
+ for (const [k, v] of Object.entries(carrier.headers)) {
18
+ if (v === void 0) continue;
19
+ if (Array.isArray(v)) {
20
+ for (const item of v) headers.append(k, item);
21
+ } else {
22
+ headers.set(k, v);
23
+ }
24
+ }
25
+ return new Request("http://localhost/", { headers });
26
+ }
27
+ function serviceKeyFromHeaders(carrier) {
28
+ return serviceKeyFromRequest(toWebRequest(carrier));
29
+ }
30
+ function createServiceKeyResolver(opts) {
31
+ const ttl = opts.cacheTtlMs ?? 6e4;
32
+ const cache = /* @__PURE__ */ new Map();
33
+ async function resolve(carrier) {
34
+ const key = serviceKeyFromHeaders(carrier);
35
+ if (!key) return { status: "absent" };
36
+ const hash = createHash("sha256").update(key).digest("hex");
37
+ const now = Date.now();
38
+ let ctx;
39
+ const cached = ttl > 0 ? cache.get(hash) : void 0;
40
+ if (cached && cached.expires > now) {
41
+ ctx = cached.value;
42
+ } else {
43
+ ctx = await introspectServiceKey({ config: opts.config, key, fetchImpl: opts.fetchImpl });
44
+ if (ttl > 0) cache.set(hash, { value: ctx, expires: now + ttl });
45
+ }
46
+ return ctx ? { status: "ok", context: ctx } : { status: "invalid" };
47
+ }
48
+ function invalidate(key) {
49
+ cache.delete(createHash("sha256").update(key).digest("hex"));
50
+ }
51
+ return { resolve, invalidate };
52
+ }
53
+ function sessionTokenFromHeaders(carrier) {
54
+ const auth = headerValue(carrier, "authorization");
55
+ if (!auth || !auth.startsWith("Bearer ")) return null;
56
+ const token = auth.slice(7).trim();
57
+ if (!token || token.startsWith("sk_")) return null;
58
+ return token;
59
+ }
60
+ function createUserSessionResolver(opts) {
61
+ const ttl = opts.cacheTtlMs ?? 6e4;
62
+ const cache = /* @__PURE__ */ new Map();
63
+ const fetchImpl = opts.fetchImpl ?? fetch;
64
+ async function introspect(token) {
65
+ const base = opts.config.baseUrl?.replace(/\/$/, "");
66
+ if (!base || !token) return null;
67
+ try {
68
+ const res = await fetchImpl(new URL("/api/auth/me", base), {
69
+ headers: { authorization: `Bearer ${token}`, accept: "application/json" }
70
+ });
71
+ if (!res.ok) return null;
72
+ const data = await res.json();
73
+ const userId = data.user?.id;
74
+ const sessionId = data.session?.id;
75
+ if (!userId || !sessionId) return null;
76
+ return {
77
+ userId,
78
+ email: data.user?.emailAddress ?? data.user?.email,
79
+ name: data.user?.name,
80
+ sessionId,
81
+ appId: data.session?.appId ?? null,
82
+ tenantId: data.session?.tenantId ?? null,
83
+ expiresAt: data.session?.expiresAt
84
+ };
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+ async function resolve(carrier) {
90
+ const token = sessionTokenFromHeaders(carrier);
91
+ if (!token) return null;
92
+ const hash = createHash("sha256").update(token).digest("hex");
93
+ const now = Date.now();
94
+ const cached = ttl > 0 ? cache.get(hash) : void 0;
95
+ if (cached && cached.expires > now) return cached.value;
96
+ const ctx = await introspect(token);
97
+ if (ttl > 0) cache.set(hash, { value: ctx, expires: now + ttl });
98
+ return ctx;
99
+ }
100
+ function invalidate(token) {
101
+ cache.delete(createHash("sha256").update(token).digest("hex"));
102
+ }
103
+ return { resolve, invalidate };
104
+ }
105
+ function hasScopes(ctx, required) {
106
+ return required.every((scope) => scopeSatisfied(ctx.scopes, scope));
107
+ }
108
+ function canAccessApp(ctx, appIdOrSlug) {
109
+ return ctx.accessibleApps.some((a) => a.id === appIdOrSlug || a.slug === appIdOrSlug);
110
+ }
111
+ var ACCOUNTS_WEBHOOK_SIGNATURE_HEADER = "x-webhook-signature";
112
+ function verifyAndParseAccountsWebhook(rawBody, signatureHeaderValue, secret) {
113
+ const signature = Array.isArray(signatureHeaderValue) ? signatureHeaderValue[0] : signatureHeaderValue;
114
+ if (!verifyWebhookSignature(rawBody, signature ?? null, secret)) {
115
+ return { ok: false, reason: "bad_signature" };
116
+ }
117
+ try {
118
+ return { ok: true, event: normalizeAccountsWebhook(JSON.parse(rawBody)) };
119
+ } catch {
120
+ return { ok: false, reason: "bad_json" };
121
+ }
122
+ }
123
+ export {
124
+ ACCOUNTS_WEBHOOK_SIGNATURE_HEADER,
125
+ canAccessApp,
126
+ createServiceKeyResolver,
127
+ createUserSessionResolver,
128
+ hasScopes,
129
+ normalizeAccountsWebhook,
130
+ serviceKeyFromHeaders,
131
+ sessionTokenFromHeaders,
132
+ toWebRequest,
133
+ verifyAndParseAccountsWebhook
134
+ };
@@ -55,7 +55,10 @@ type AccountsWebhookOptions = {
55
55
  secret: string;
56
56
  /** Route to receive webhooks on. Default '/api/auth/accounts/webhook'. */
57
57
  path?: string;
58
- /** Header carrying the signature. Default 'x-accounts-signature'. */
58
+ /**
59
+ * Header carrying the signature. Default 'x-webhook-signature' — the header the Accounts
60
+ * webhook worker actually sends (HMAC-SHA256 hex of the raw body).
61
+ */
59
62
  signatureHeader?: string;
60
63
  /** React to a verified event. Throwing yields a 500 (Accounts may retry). */
61
64
  onEvent: (event: AccountsWebhookEvent, raw: string, request: RequestEvent) => void | Promise<void>;
package/dist/sveltekit.js CHANGED
@@ -5,10 +5,11 @@ import {
5
5
  identityPassthroughSync,
6
6
  introspectServiceKey,
7
7
  logoutFromAccounts,
8
+ normalizeAccountsWebhook,
8
9
  readPlatformSessionCookie,
9
10
  serviceKeyFromRequest,
10
11
  verifyWebhookSignature
11
- } from "./chunk-QIAL6IDV.js";
12
+ } from "./chunk-TQNQLX3C.js";
12
13
 
13
14
  // src/sveltekit.ts
14
15
  import { createHash } from "crypto";
@@ -113,21 +114,21 @@ function jsonResponse(status, body) {
113
114
  }
114
115
  function createAccountsWebhookHandle(opts) {
115
116
  const path = opts.path ?? "/api/auth/accounts/webhook";
116
- const sigHeader = opts.signatureHeader ?? "x-accounts-signature";
117
+ const sigHeader = opts.signatureHeader ?? "x-webhook-signature";
117
118
  return async ({ event, resolve }) => {
118
119
  if (event.url.pathname !== path || event.request.method !== "POST") return resolve(event);
119
120
  const raw = await event.request.text();
120
121
  if (!verifyWebhookSignature(raw, event.request.headers.get(sigHeader), opts.secret)) {
121
122
  return jsonResponse(401, { ok: false, error: "Invalid webhook signature." });
122
123
  }
123
- let payload;
124
+ let parsed;
124
125
  try {
125
- payload = JSON.parse(raw);
126
+ parsed = normalizeAccountsWebhook(JSON.parse(raw));
126
127
  } catch {
127
128
  return jsonResponse(400, { ok: false, error: "Invalid JSON body." });
128
129
  }
129
130
  try {
130
- await opts.onEvent({ ...payload, type: String(payload.type ?? "unknown") }, raw, event);
131
+ await opts.onEvent(parsed, raw, event);
131
132
  } catch {
132
133
  return jsonResponse(500, { ok: false, error: "Webhook handler failed." });
133
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@~lyre/auth",
3
- "version": "0.0.9",
3
+ "version": "0.3.0",
4
4
  "description": "Shared Axis Accounts auth SDK — framework-agnostic session/identity core (HMAC-signed cookies, accounts login/callback/logout) plus an optional turnkey SvelteKit adapter.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -33,7 +33,16 @@
33
33
  "./sveltekit": {
34
34
  "types": "./dist/sveltekit.d.ts",
35
35
  "import": "./dist/sveltekit.js"
36
- }
36
+ },
37
+ "./nest": {
38
+ "types": "./dist/nest.d.ts",
39
+ "import": "./dist/nest.js"
40
+ },
41
+ "./admin": {
42
+ "types": "./dist/admin.d.ts",
43
+ "import": "./dist/admin.js"
44
+ },
45
+ "./package.json": "./package.json"
37
46
  },
38
47
  "files": [
39
48
  "dist",
@@ -41,7 +50,7 @@
41
50
  "LICENSE"
42
51
  ],
43
52
  "scripts": {
44
- "build": "tsup src/index.ts src/sveltekit.ts --format esm --dts --clean",
53
+ "build": "tsup src/index.ts src/sveltekit.ts src/nest.ts src/admin.ts --format esm --dts --clean",
45
54
  "check": "tsc --noEmit"
46
55
  },
47
56
  "peerDependencies": {