@emdash-cms/auth 0.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.
Files changed (48) hide show
  1. package/dist/adapters/kysely.d.mts +62 -0
  2. package/dist/adapters/kysely.d.mts.map +1 -0
  3. package/dist/adapters/kysely.mjs +379 -0
  4. package/dist/adapters/kysely.mjs.map +1 -0
  5. package/dist/authenticate-D5UgaoTH.d.mts +124 -0
  6. package/dist/authenticate-D5UgaoTH.d.mts.map +1 -0
  7. package/dist/authenticate-j5GayLXB.mjs +373 -0
  8. package/dist/authenticate-j5GayLXB.mjs.map +1 -0
  9. package/dist/index.d.mts +444 -0
  10. package/dist/index.d.mts.map +1 -0
  11. package/dist/index.mjs +728 -0
  12. package/dist/index.mjs.map +1 -0
  13. package/dist/oauth/providers/github.d.mts +12 -0
  14. package/dist/oauth/providers/github.d.mts.map +1 -0
  15. package/dist/oauth/providers/github.mjs +55 -0
  16. package/dist/oauth/providers/github.mjs.map +1 -0
  17. package/dist/oauth/providers/google.d.mts +7 -0
  18. package/dist/oauth/providers/google.d.mts.map +1 -0
  19. package/dist/oauth/providers/google.mjs +38 -0
  20. package/dist/oauth/providers/google.mjs.map +1 -0
  21. package/dist/passkey/index.d.mts +2 -0
  22. package/dist/passkey/index.mjs +3 -0
  23. package/dist/types-Bu4irX9A.d.mts +35 -0
  24. package/dist/types-Bu4irX9A.d.mts.map +1 -0
  25. package/dist/types-CiSNpRI9.mjs +60 -0
  26. package/dist/types-CiSNpRI9.mjs.map +1 -0
  27. package/dist/types-HtRc90Wi.d.mts +208 -0
  28. package/dist/types-HtRc90Wi.d.mts.map +1 -0
  29. package/package.json +72 -0
  30. package/src/adapters/kysely.ts +715 -0
  31. package/src/config.ts +214 -0
  32. package/src/index.ts +135 -0
  33. package/src/invite.ts +205 -0
  34. package/src/magic-link/index.ts +150 -0
  35. package/src/oauth/consumer.ts +324 -0
  36. package/src/oauth/providers/github.ts +68 -0
  37. package/src/oauth/providers/google.ts +34 -0
  38. package/src/oauth/types.ts +36 -0
  39. package/src/passkey/authenticate.ts +183 -0
  40. package/src/passkey/index.ts +27 -0
  41. package/src/passkey/register.ts +232 -0
  42. package/src/passkey/types.ts +120 -0
  43. package/src/rbac.test.ts +141 -0
  44. package/src/rbac.ts +205 -0
  45. package/src/signup.ts +210 -0
  46. package/src/tokens.test.ts +141 -0
  47. package/src/tokens.ts +238 -0
  48. package/src/types.ts +352 -0
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Passkey registration (credential creation)
3
+ *
4
+ * Based on oslo webauthn documentation:
5
+ * https://webauthn.oslojs.dev/examples/registration
6
+ */
7
+
8
+ import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa";
9
+ import { encodeBase64urlNoPadding, decodeBase64urlIgnorePadding } from "@oslojs/encoding";
10
+ import {
11
+ parseAttestationObject,
12
+ parseClientDataJSON,
13
+ coseAlgorithmES256,
14
+ coseAlgorithmRS256,
15
+ coseEllipticCurveP256,
16
+ ClientDataType,
17
+ AttestationStatementFormat,
18
+ COSEKeyType,
19
+ } from "@oslojs/webauthn";
20
+
21
+ import { generateToken } from "../tokens.js";
22
+ import type { Credential, NewCredential, AuthAdapter, User, DeviceType } from "../types.js";
23
+ import type {
24
+ RegistrationOptions,
25
+ RegistrationResponse,
26
+ VerifiedRegistration,
27
+ ChallengeStore,
28
+ PasskeyConfig,
29
+ } from "./types.js";
30
+
31
+ const CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes
32
+
33
+ export type { PasskeyConfig };
34
+
35
+ /**
36
+ * Generate registration options for creating a new passkey
37
+ */
38
+ export async function generateRegistrationOptions(
39
+ config: PasskeyConfig,
40
+ user: Pick<User, "id" | "email" | "name">,
41
+ existingCredentials: Credential[],
42
+ challengeStore: ChallengeStore,
43
+ ): Promise<RegistrationOptions> {
44
+ const challenge = generateToken();
45
+
46
+ // Store challenge for verification
47
+ await challengeStore.set(challenge, {
48
+ type: "registration",
49
+ userId: user.id,
50
+ expiresAt: Date.now() + CHALLENGE_TTL,
51
+ });
52
+
53
+ // Encode user ID as base64url
54
+ const userIdBytes = new TextEncoder().encode(user.id);
55
+ const userIdEncoded = encodeBase64urlNoPadding(userIdBytes);
56
+
57
+ return {
58
+ challenge,
59
+ rp: {
60
+ name: config.rpName,
61
+ id: config.rpId,
62
+ },
63
+ user: {
64
+ id: userIdEncoded,
65
+ name: user.email,
66
+ displayName: user.name || user.email,
67
+ },
68
+ pubKeyCredParams: [
69
+ { type: "public-key", alg: coseAlgorithmES256 }, // ES256 (-7)
70
+ { type: "public-key", alg: coseAlgorithmRS256 }, // RS256 (-257)
71
+ ],
72
+ timeout: 60000,
73
+ attestation: "none", // We don't need attestation for our use case
74
+ authenticatorSelection: {
75
+ residentKey: "preferred", // Allow discoverable credentials
76
+ userVerification: "preferred",
77
+ },
78
+ excludeCredentials: existingCredentials.map((cred) => ({
79
+ type: "public-key" as const,
80
+ id: cred.id,
81
+ transports: cred.transports,
82
+ })),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Verify a registration response and extract credential data
88
+ */
89
+ export async function verifyRegistrationResponse(
90
+ config: PasskeyConfig,
91
+ response: RegistrationResponse,
92
+ challengeStore: ChallengeStore,
93
+ ): Promise<VerifiedRegistration> {
94
+ // Decode the response
95
+ const clientDataJSON = decodeBase64urlIgnorePadding(response.response.clientDataJSON);
96
+ const attestationObject = decodeBase64urlIgnorePadding(response.response.attestationObject);
97
+
98
+ // Parse client data
99
+ const clientData = parseClientDataJSON(clientDataJSON);
100
+
101
+ // Verify client data
102
+ if (clientData.type !== ClientDataType.Create) {
103
+ throw new Error("Invalid client data type");
104
+ }
105
+
106
+ // Verify challenge - convert Uint8Array back to base64url string (no padding, matching stored format)
107
+ const challengeString = encodeBase64urlNoPadding(clientData.challenge);
108
+ const challengeData = await challengeStore.get(challengeString);
109
+ if (!challengeData) {
110
+ throw new Error("Challenge not found or expired");
111
+ }
112
+ if (challengeData.type !== "registration") {
113
+ throw new Error("Invalid challenge type");
114
+ }
115
+ if (challengeData.expiresAt < Date.now()) {
116
+ await challengeStore.delete(challengeString);
117
+ throw new Error("Challenge expired");
118
+ }
119
+
120
+ // Delete challenge (single-use)
121
+ await challengeStore.delete(challengeString);
122
+
123
+ // Verify origin
124
+ if (clientData.origin !== config.origin) {
125
+ throw new Error(`Invalid origin: expected ${config.origin}, got ${clientData.origin}`);
126
+ }
127
+
128
+ // Parse attestation object
129
+ const attestation = parseAttestationObject(attestationObject);
130
+
131
+ // We only support 'none' attestation for simplicity
132
+ if (attestation.attestationStatement.format !== AttestationStatementFormat.None) {
133
+ // For other formats, we'd need to verify the attestation statement
134
+ // For now, we just ignore it and trust the credential
135
+ }
136
+
137
+ const { authenticatorData } = attestation;
138
+
139
+ // Verify RP ID hash
140
+ if (!authenticatorData.verifyRelyingPartyIdHash(config.rpId)) {
141
+ throw new Error("Invalid RP ID hash");
142
+ }
143
+
144
+ // Verify flags
145
+ if (!authenticatorData.userPresent) {
146
+ throw new Error("User presence not verified");
147
+ }
148
+
149
+ // Extract credential data
150
+ if (!authenticatorData.credential) {
151
+ throw new Error("No credential data in attestation");
152
+ }
153
+
154
+ const { credential } = authenticatorData;
155
+
156
+ // Verify algorithm is supported and encode public key
157
+ // Currently only supporting ES256 (ECDSA with P-256)
158
+ const algorithm = credential.publicKey.algorithm();
159
+ let encodedPublicKey: Uint8Array;
160
+
161
+ if (algorithm === coseAlgorithmES256) {
162
+ // Verify it's EC2 key type
163
+ if (credential.publicKey.type() !== COSEKeyType.EC2) {
164
+ throw new Error("Expected EC2 key type for ES256");
165
+ }
166
+ const cosePublicKey = credential.publicKey.ec2();
167
+ if (cosePublicKey.curve !== coseEllipticCurveP256) {
168
+ throw new Error("Expected P-256 curve for ES256");
169
+ }
170
+ // Encode as SEC1 uncompressed format for storage
171
+ encodedPublicKey = new ECDSAPublicKey(
172
+ p256,
173
+ cosePublicKey.x,
174
+ cosePublicKey.y,
175
+ ).encodeSEC1Uncompressed();
176
+ } else if (algorithm === coseAlgorithmRS256) {
177
+ // RSA is less common for passkeys, skip for now
178
+ throw new Error("RS256 not yet supported - please use ES256");
179
+ } else {
180
+ throw new Error(`Unsupported algorithm: ${algorithm}`);
181
+ }
182
+
183
+ // Determine device type and backup status
184
+ // Note: oslo webauthn doesn't expose backup flags, so we default to singleDevice
185
+ // In practice, most modern passkeys are multi-device (e.g., iCloud Keychain, Google Password Manager)
186
+ const deviceType: DeviceType = "singleDevice";
187
+ const backedUp = false;
188
+
189
+ return {
190
+ credentialId: response.id,
191
+ publicKey: encodedPublicKey,
192
+ counter: authenticatorData.signatureCounter,
193
+ deviceType,
194
+ backedUp,
195
+ transports: response.response.transports ?? [],
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Register a new passkey for a user
201
+ */
202
+ export async function registerPasskey(
203
+ adapter: AuthAdapter,
204
+ userId: string,
205
+ verified: VerifiedRegistration,
206
+ name?: string,
207
+ ): Promise<Credential> {
208
+ // Check credential limit
209
+ const count = await adapter.countCredentialsByUserId(userId);
210
+ if (count >= 10) {
211
+ throw new Error("Maximum number of passkeys reached (10)");
212
+ }
213
+
214
+ // Check if credential already exists
215
+ const existing = await adapter.getCredentialById(verified.credentialId);
216
+ if (existing) {
217
+ throw new Error("Credential already registered");
218
+ }
219
+
220
+ const newCredential: NewCredential = {
221
+ id: verified.credentialId,
222
+ userId,
223
+ publicKey: verified.publicKey,
224
+ counter: verified.counter,
225
+ deviceType: verified.deviceType,
226
+ backedUp: verified.backedUp,
227
+ transports: verified.transports,
228
+ name,
229
+ };
230
+
231
+ return adapter.createCredential(newCredential);
232
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * WebAuthn types for passkey authentication
3
+ */
4
+
5
+ import type { AuthenticatorTransport, DeviceType } from "../types.js";
6
+
7
+ // ============================================================================
8
+ // Registration (Creating a new passkey)
9
+ // ============================================================================
10
+
11
+ export interface RegistrationOptions {
12
+ challenge: string; // Base64url encoded
13
+ rp: {
14
+ name: string;
15
+ id: string;
16
+ };
17
+ user: {
18
+ id: string; // Base64url encoded user ID
19
+ name: string;
20
+ displayName: string;
21
+ };
22
+ pubKeyCredParams: Array<{
23
+ type: "public-key";
24
+ alg: number; // COSE algorithm identifier
25
+ }>;
26
+ timeout?: number;
27
+ attestation?: "none" | "indirect" | "direct";
28
+ authenticatorSelection?: {
29
+ authenticatorAttachment?: "platform" | "cross-platform";
30
+ residentKey?: "discouraged" | "preferred" | "required";
31
+ requireResidentKey?: boolean;
32
+ userVerification?: "discouraged" | "preferred" | "required";
33
+ };
34
+ excludeCredentials?: Array<{
35
+ type: "public-key";
36
+ id: string; // Base64url encoded credential ID
37
+ transports?: AuthenticatorTransport[];
38
+ }>;
39
+ }
40
+
41
+ export interface RegistrationResponse {
42
+ id: string; // Base64url credential ID
43
+ rawId: string; // Base64url
44
+ type: "public-key";
45
+ response: {
46
+ clientDataJSON: string; // Base64url
47
+ attestationObject: string; // Base64url
48
+ transports?: AuthenticatorTransport[];
49
+ };
50
+ authenticatorAttachment?: "platform" | "cross-platform";
51
+ }
52
+
53
+ export interface VerifiedRegistration {
54
+ credentialId: string;
55
+ publicKey: Uint8Array;
56
+ counter: number;
57
+ deviceType: DeviceType;
58
+ backedUp: boolean;
59
+ transports: AuthenticatorTransport[];
60
+ }
61
+
62
+ // ============================================================================
63
+ // Authentication (Using an existing passkey)
64
+ // ============================================================================
65
+
66
+ export interface AuthenticationOptions {
67
+ challenge: string; // Base64url encoded
68
+ rpId: string;
69
+ timeout?: number;
70
+ userVerification?: "discouraged" | "preferred" | "required";
71
+ allowCredentials?: Array<{
72
+ type: "public-key";
73
+ id: string; // Base64url encoded credential ID
74
+ transports?: AuthenticatorTransport[];
75
+ }>;
76
+ }
77
+
78
+ export interface AuthenticationResponse {
79
+ id: string; // Base64url credential ID
80
+ rawId: string; // Base64url
81
+ type: "public-key";
82
+ response: {
83
+ clientDataJSON: string; // Base64url
84
+ authenticatorData: string; // Base64url
85
+ signature: string; // Base64url
86
+ userHandle?: string; // Base64url (user ID)
87
+ };
88
+ authenticatorAttachment?: "platform" | "cross-platform";
89
+ }
90
+
91
+ export interface VerifiedAuthentication {
92
+ credentialId: string;
93
+ newCounter: number;
94
+ }
95
+
96
+ // ============================================================================
97
+ // Challenge storage
98
+ // ============================================================================
99
+
100
+ export interface ChallengeStore {
101
+ set(challenge: string, data: ChallengeData): Promise<void>;
102
+ get(challenge: string): Promise<ChallengeData | null>;
103
+ delete(challenge: string): Promise<void>;
104
+ }
105
+
106
+ export interface ChallengeData {
107
+ type: "registration" | "authentication";
108
+ userId?: string; // For registration, the user being registered
109
+ expiresAt: number;
110
+ }
111
+
112
+ // ============================================================================
113
+ // Passkey Configuration
114
+ // ============================================================================
115
+
116
+ export interface PasskeyConfig {
117
+ rpName: string;
118
+ rpId: string;
119
+ origin: string;
120
+ }
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ import {
4
+ hasPermission,
5
+ requirePermission,
6
+ canActOnOwn,
7
+ requirePermissionOnResource,
8
+ PermissionError,
9
+ } from "./rbac.js";
10
+ import { Role } from "./types.js";
11
+
12
+ describe("rbac", () => {
13
+ describe("hasPermission", () => {
14
+ it("returns false for null user", () => {
15
+ expect(hasPermission(null, "content:read")).toBe(false);
16
+ });
17
+
18
+ it("returns false for undefined user", () => {
19
+ expect(hasPermission(undefined, "content:read")).toBe(false);
20
+ });
21
+
22
+ it("allows subscriber to read content", () => {
23
+ expect(hasPermission({ role: Role.SUBSCRIBER }, "content:read")).toBe(true);
24
+ });
25
+
26
+ it("denies subscriber from creating content", () => {
27
+ expect(hasPermission({ role: Role.SUBSCRIBER }, "content:create")).toBe(false);
28
+ });
29
+
30
+ it("allows contributor to create content", () => {
31
+ expect(hasPermission({ role: Role.CONTRIBUTOR }, "content:create")).toBe(true);
32
+ });
33
+
34
+ it("allows admin to do anything", () => {
35
+ const admin = { role: Role.ADMIN };
36
+ expect(hasPermission(admin, "content:read")).toBe(true);
37
+ expect(hasPermission(admin, "content:create")).toBe(true);
38
+ expect(hasPermission(admin, "users:manage")).toBe(true);
39
+ expect(hasPermission(admin, "schema:manage")).toBe(true);
40
+ });
41
+
42
+ it("denies editor from managing users", () => {
43
+ expect(hasPermission({ role: Role.EDITOR }, "users:manage")).toBe(false);
44
+ });
45
+
46
+ it("allows author to edit own media", () => {
47
+ expect(hasPermission({ role: Role.AUTHOR }, "media:edit_own")).toBe(true);
48
+ });
49
+
50
+ it("denies contributor from editing media", () => {
51
+ expect(hasPermission({ role: Role.CONTRIBUTOR }, "media:edit_own")).toBe(false);
52
+ });
53
+
54
+ it("allows editor to edit any media", () => {
55
+ expect(hasPermission({ role: Role.EDITOR }, "media:edit_any")).toBe(true);
56
+ });
57
+
58
+ it("denies author from editing any media", () => {
59
+ expect(hasPermission({ role: Role.AUTHOR }, "media:edit_any")).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe("requirePermission", () => {
64
+ it("throws for null user", () => {
65
+ expect(() => requirePermission(null, "content:read")).toThrow(PermissionError);
66
+ });
67
+
68
+ it("throws unauthorized for missing user", () => {
69
+ try {
70
+ requirePermission(null, "content:read");
71
+ } catch (e) {
72
+ expect(e).toBeInstanceOf(PermissionError);
73
+ expect((e as PermissionError).code).toBe("unauthorized");
74
+ }
75
+ });
76
+
77
+ it("throws forbidden for insufficient permissions", () => {
78
+ try {
79
+ requirePermission({ role: Role.SUBSCRIBER }, "content:create");
80
+ } catch (e) {
81
+ expect(e).toBeInstanceOf(PermissionError);
82
+ expect((e as PermissionError).code).toBe("forbidden");
83
+ }
84
+ });
85
+
86
+ it("does not throw for sufficient permissions", () => {
87
+ expect(() => requirePermission({ role: Role.ADMIN }, "content:create")).not.toThrow();
88
+ });
89
+ });
90
+
91
+ describe("canActOnOwn", () => {
92
+ const user = { role: Role.AUTHOR, id: "user-1" };
93
+
94
+ it("allows action on own resource with own permission", () => {
95
+ expect(canActOnOwn(user, "user-1", "content:edit_own", "content:edit_any")).toBe(true);
96
+ });
97
+
98
+ it("denies action on others resource without any permission", () => {
99
+ expect(canActOnOwn(user, "user-2", "content:edit_own", "content:edit_any")).toBe(false);
100
+ });
101
+
102
+ it("allows editor to edit any resource", () => {
103
+ const editor = { role: Role.EDITOR, id: "editor-1" };
104
+ expect(canActOnOwn(editor, "user-2", "content:edit_own", "content:edit_any")).toBe(true);
105
+ });
106
+
107
+ it("allows author to edit own media", () => {
108
+ expect(canActOnOwn(user, "user-1", "media:edit_own", "media:edit_any")).toBe(true);
109
+ });
110
+
111
+ it("denies author from editing others media", () => {
112
+ expect(canActOnOwn(user, "user-2", "media:edit_own", "media:edit_any")).toBe(false);
113
+ });
114
+
115
+ it("denies contributor from editing any media (including own)", () => {
116
+ const contributor = { role: Role.CONTRIBUTOR, id: "contrib-1" };
117
+ expect(canActOnOwn(contributor, "contrib-1", "media:edit_own", "media:edit_any")).toBe(false);
118
+ });
119
+
120
+ it("allows editor to edit any media", () => {
121
+ const editor = { role: Role.EDITOR, id: "editor-1" };
122
+ expect(canActOnOwn(editor, "user-2", "media:edit_own", "media:edit_any")).toBe(true);
123
+ });
124
+ });
125
+
126
+ describe("requirePermissionOnResource", () => {
127
+ it("allows author to edit own content", () => {
128
+ const user = { role: Role.AUTHOR, id: "user-1" };
129
+ expect(() =>
130
+ requirePermissionOnResource(user, "user-1", "content:edit_own", "content:edit_any"),
131
+ ).not.toThrow();
132
+ });
133
+
134
+ it("throws for author editing others content", () => {
135
+ const user = { role: Role.AUTHOR, id: "user-1" };
136
+ expect(() =>
137
+ requirePermissionOnResource(user, "user-2", "content:edit_own", "content:edit_any"),
138
+ ).toThrow(PermissionError);
139
+ });
140
+ });
141
+ });
package/src/rbac.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Role-Based Access Control
3
+ */
4
+
5
+ import type { ApiTokenScope } from "./tokens.js";
6
+ import { Role, type RoleLevel } from "./types.js";
7
+
8
+ /**
9
+ * Permission definitions with minimum role required
10
+ */
11
+ export const Permissions = {
12
+ // Content
13
+ "content:read": Role.SUBSCRIBER,
14
+ "content:create": Role.CONTRIBUTOR,
15
+ "content:edit_own": Role.AUTHOR,
16
+ "content:edit_any": Role.EDITOR,
17
+ "content:delete_own": Role.AUTHOR,
18
+ "content:delete_any": Role.EDITOR,
19
+ "content:publish_own": Role.AUTHOR,
20
+ "content:publish_any": Role.EDITOR,
21
+
22
+ // Media
23
+ "media:read": Role.SUBSCRIBER,
24
+ "media:upload": Role.CONTRIBUTOR,
25
+ "media:edit_own": Role.AUTHOR,
26
+ "media:edit_any": Role.EDITOR,
27
+ "media:delete_own": Role.AUTHOR,
28
+ "media:delete_any": Role.EDITOR,
29
+
30
+ // Taxonomies
31
+ "taxonomies:read": Role.SUBSCRIBER,
32
+ "taxonomies:manage": Role.EDITOR,
33
+
34
+ // Comments
35
+ "comments:read": Role.SUBSCRIBER,
36
+ "comments:moderate": Role.EDITOR,
37
+ "comments:delete": Role.ADMIN,
38
+ "comments:settings": Role.ADMIN,
39
+
40
+ // Menus
41
+ "menus:read": Role.SUBSCRIBER,
42
+ "menus:manage": Role.EDITOR,
43
+
44
+ // Widgets
45
+ "widgets:read": Role.SUBSCRIBER,
46
+ "widgets:manage": Role.EDITOR,
47
+
48
+ // Sections
49
+ "sections:read": Role.SUBSCRIBER,
50
+ "sections:manage": Role.EDITOR,
51
+
52
+ // Redirects
53
+ "redirects:read": Role.EDITOR,
54
+ "redirects:manage": Role.ADMIN,
55
+
56
+ // Users
57
+ "users:read": Role.ADMIN,
58
+ "users:invite": Role.ADMIN,
59
+ "users:manage": Role.ADMIN,
60
+
61
+ // Settings
62
+ "settings:read": Role.EDITOR,
63
+ "settings:manage": Role.ADMIN,
64
+
65
+ // Schema (content types)
66
+ "schema:read": Role.EDITOR,
67
+ "schema:manage": Role.ADMIN,
68
+
69
+ // Plugins
70
+ "plugins:read": Role.EDITOR,
71
+ "plugins:manage": Role.ADMIN,
72
+
73
+ // Import
74
+ "import:execute": Role.ADMIN,
75
+
76
+ // Search
77
+ "search:read": Role.SUBSCRIBER,
78
+ "search:manage": Role.ADMIN,
79
+
80
+ // Auth
81
+ "auth:manage_own_credentials": Role.SUBSCRIBER,
82
+ "auth:manage_connections": Role.ADMIN,
83
+ } as const;
84
+
85
+ export type Permission = keyof typeof Permissions;
86
+
87
+ /**
88
+ * Check if a user has a specific permission
89
+ */
90
+ export function hasPermission(
91
+ user: { role: RoleLevel } | null | undefined,
92
+ permission: Permission,
93
+ ): boolean {
94
+ if (!user) return false;
95
+ return user.role >= Permissions[permission];
96
+ }
97
+
98
+ /**
99
+ * Require a permission, throwing if not met
100
+ */
101
+ export function requirePermission(
102
+ user: { role: RoleLevel } | null | undefined,
103
+ permission: Permission,
104
+ ): asserts user is { role: RoleLevel } {
105
+ if (!user) {
106
+ throw new PermissionError("unauthorized", "Authentication required");
107
+ }
108
+ if (!hasPermission(user, permission)) {
109
+ throw new PermissionError("forbidden", `Missing permission: ${permission}`);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Check if user can perform action on a resource they own
115
+ */
116
+ export function canActOnOwn(
117
+ user: { role: RoleLevel; id: string } | null | undefined,
118
+ ownerId: string,
119
+ ownPermission: Permission,
120
+ anyPermission: Permission,
121
+ ): boolean {
122
+ if (!user) return false;
123
+ if (user.id === ownerId) {
124
+ return hasPermission(user, ownPermission);
125
+ }
126
+ return hasPermission(user, anyPermission);
127
+ }
128
+
129
+ /**
130
+ * Require permission on a resource, checking ownership
131
+ */
132
+ export function requirePermissionOnResource(
133
+ user: { role: RoleLevel; id: string } | null | undefined,
134
+ ownerId: string,
135
+ ownPermission: Permission,
136
+ anyPermission: Permission,
137
+ ): asserts user is { role: RoleLevel; id: string } {
138
+ if (!user) {
139
+ throw new PermissionError("unauthorized", "Authentication required");
140
+ }
141
+ if (!canActOnOwn(user, ownerId, ownPermission, anyPermission)) {
142
+ throw new PermissionError("forbidden", `Missing permission: ${anyPermission}`);
143
+ }
144
+ }
145
+
146
+ export class PermissionError extends Error {
147
+ constructor(
148
+ public code: "unauthorized" | "forbidden",
149
+ message: string,
150
+ ) {
151
+ super(message);
152
+ this.name = "PermissionError";
153
+ }
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // API Token Scope ↔ Role mapping
158
+ //
159
+ // Maps each API token scope to the minimum RBAC role required to hold it.
160
+ // Used at token issuance time to clamp granted scopes to the user's role.
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /**
164
+ * Minimum role required for each API token scope.
165
+ *
166
+ * This is the authoritative mapping between the two authorization systems
167
+ * (RBAC roles and API token scopes). When issuing a token, the granted
168
+ * scopes must be intersected with the scopes allowed by the user's role.
169
+ */
170
+ const SCOPE_MIN_ROLE: Record<ApiTokenScope, RoleLevel> = {
171
+ "content:read": Role.SUBSCRIBER,
172
+ "content:write": Role.CONTRIBUTOR,
173
+ "media:read": Role.SUBSCRIBER,
174
+ "media:write": Role.CONTRIBUTOR,
175
+ "schema:read": Role.EDITOR,
176
+ "schema:write": Role.ADMIN,
177
+ admin: Role.ADMIN,
178
+ };
179
+
180
+ /**
181
+ * Return the maximum set of API token scopes a given role level may hold.
182
+ *
183
+ * Used at token issuance time (device flow, authorization code exchange)
184
+ * to enforce: effective_scopes = requested_scopes ∩ scopesForRole(role).
185
+ */
186
+ export function scopesForRole(role: RoleLevel): ApiTokenScope[] {
187
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Object.entries loses tuple types; SCOPE_MIN_ROLE keys are ApiTokenScope by construction
188
+ const entries = Object.entries(SCOPE_MIN_ROLE) as [ApiTokenScope, RoleLevel][];
189
+ return entries.reduce<ApiTokenScope[]>((acc, [scope, minRole]) => {
190
+ if (role >= minRole) acc.push(scope);
191
+ return acc;
192
+ }, []);
193
+ }
194
+
195
+ /**
196
+ * Clamp a set of requested scopes to those permitted by a user's role.
197
+ *
198
+ * Returns the intersection of `requested` and the scopes the role allows.
199
+ * This is the central policy enforcement point: effective permissions =
200
+ * role permissions ∩ token scopes.
201
+ */
202
+ export function clampScopes(requested: string[], role: RoleLevel): string[] {
203
+ const allowed = new Set<string>(scopesForRole(role));
204
+ return requested.filter((s) => allowed.has(s));
205
+ }