@oglofus/auth 1.0.1 → 1.1.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.
@@ -1,17 +1,31 @@
1
+ import { addSeconds, cloneWithout, createId, createNumericCode, secretHash, secretVerify } from "../core/utils.js";
2
+ import { ensureFields } from "../core/validators.js";
1
3
  import { AuthError } from "../errors/index.js";
2
4
  import { createIssue } from "../issues/index.js";
3
5
  import { errorOperation, successOperation } from "../types/results.js";
4
- import { addSeconds, createId, createNumericCode, secretHash, secretVerify, } from "../core/utils.js";
5
- import { cloneWithout } from "../core/utils.js";
6
- import { ensureFields } from "../core/validators.js";
7
6
  const PENDING_PREFIX = "pending:";
7
+ const EMAIL_OTP_REQUEST_POLICY = { limit: 3, windowSeconds: 300 };
8
+ const OTP_VERIFY_POLICY = { limit: 10, windowSeconds: 300 };
8
9
  const isPendingUserId = (value) => value.startsWith(PENDING_PREFIX);
10
+ const createRateLimitedError = (retryAfterSeconds) => new AuthError("RATE_LIMITED", "Too many requests.", 429, [], retryAfterSeconds === undefined ? undefined : { retryAfterSeconds });
9
11
  export const emailOtpPlugin = (config) => {
10
12
  const ttl = config.challengeTtlSeconds ?? 10 * 60;
11
13
  const maxAttempts = config.maxAttempts ?? 5;
12
14
  const codeLength = config.codeLength ?? 6;
13
- const verifyChallenge = async (challengeId, code, now) => {
15
+ const verifyChallenge = async (challengeId, code, now, request, security, rateLimiter) => {
14
16
  const challenge = await config.otp.findChallengeById(challengeId);
17
+ if (rateLimiter) {
18
+ const policy = security?.rateLimits?.otpVerify ?? OTP_VERIFY_POLICY;
19
+ const result = await rateLimiter.consume(request?.ip
20
+ ? `otpVerify:ip:${request.ip}:identity:${challenge?.email ?? `challenge:${challengeId}`}`
21
+ : `otpVerify:identity:${challenge?.email ?? `challenge:${challengeId}`}`, policy.limit, policy.windowSeconds);
22
+ if (!result.allowed) {
23
+ return {
24
+ ok: false,
25
+ error: createRateLimitedError(result.retryAfterSeconds),
26
+ };
27
+ }
28
+ }
15
29
  if (!challenge) {
16
30
  return {
17
31
  ok: false,
@@ -23,9 +37,7 @@ export const emailOtpPlugin = (config) => {
23
37
  if (challenge.expiresAt.getTime() <= now.getTime()) {
24
38
  return {
25
39
  ok: false,
26
- error: new AuthError("OTP_EXPIRED", "OTP has expired.", 400, [
27
- createIssue("OTP expired", ["code"]),
28
- ]),
40
+ error: new AuthError("OTP_EXPIRED", "OTP has expired.", 400, [createIssue("OTP expired", ["code"])]),
29
41
  };
30
42
  }
31
43
  if (challenge.attempts >= maxAttempts) {
@@ -45,9 +57,7 @@ export const emailOtpPlugin = (config) => {
45
57
  }
46
58
  return {
47
59
  ok: false,
48
- error: new AuthError("OTP_INVALID", "Invalid OTP code.", 400, [
49
- createIssue("Invalid code", ["code"]),
50
- ]),
60
+ error: new AuthError("OTP_INVALID", "Invalid OTP code.", 400, [createIssue("Invalid code", ["code"])]),
51
61
  };
52
62
  }
53
63
  const consumed = await config.otp.consumeChallenge(challengeId);
@@ -68,7 +78,7 @@ export const emailOtpPlugin = (config) => {
68
78
  return {
69
79
  kind: "auth_method",
70
80
  method: "email_otp",
71
- version: "1.0.0",
81
+ version: "2.0.0",
72
82
  supports: {
73
83
  register: true,
74
84
  },
@@ -79,6 +89,13 @@ export const emailOtpPlugin = (config) => {
79
89
  createApi: (ctx) => ({
80
90
  request: async (input, request) => {
81
91
  const email = input.email.trim().toLowerCase();
92
+ if (ctx.adapters.rateLimiter) {
93
+ const policy = ctx.security?.rateLimits?.emailOtpRequest ?? EMAIL_OTP_REQUEST_POLICY;
94
+ const limited = await ctx.adapters.rateLimiter.consume(request?.ip ? `emailOtpRequest:ip:${request.ip}:identity:${email}` : `emailOtpRequest:identity:${email}`, policy.limit, policy.windowSeconds);
95
+ if (!limited.allowed) {
96
+ return errorOperation(createRateLimitedError(limited.retryAfterSeconds));
97
+ }
98
+ }
82
99
  const user = await ctx.adapters.users.findByEmail(email);
83
100
  const code = createNumericCode(codeLength);
84
101
  const challenge = await config.otp.createChallenge({
@@ -116,7 +133,7 @@ export const emailOtpPlugin = (config) => {
116
133
  },
117
134
  }),
118
135
  register: async (ctx, input) => {
119
- const verified = await verifyChallenge(input.challengeId, input.code, ctx.now());
136
+ const verified = await verifyChallenge(input.challengeId, input.code, ctx.now(), ctx.request, ctx.security, ctx.adapters.rateLimiter);
120
137
  if (!verified.ok) {
121
138
  return errorOperation(verified.error);
122
139
  }
@@ -130,18 +147,14 @@ export const emailOtpPlugin = (config) => {
130
147
  if (requiredError) {
131
148
  return errorOperation(requiredError);
132
149
  }
133
- const payload = cloneWithout(input, [
134
- "method",
135
- "challengeId",
136
- "code",
137
- ]);
150
+ const payload = cloneWithout(input, ["method", "challengeId", "code"]);
138
151
  payload.email = verified.email;
139
152
  payload.emailVerified = true;
140
153
  const user = await ctx.adapters.users.create(payload);
141
154
  return successOperation({ user });
142
155
  },
143
156
  authenticate: async (ctx, input) => {
144
- const verified = await verifyChallenge(input.challengeId, input.code, ctx.now());
157
+ const verified = await verifyChallenge(input.challengeId, input.code, ctx.now(), ctx.request, ctx.security, ctx.adapters.rateLimiter);
145
158
  if (!verified.ok) {
146
159
  return errorOperation(verified.error);
147
160
  }
@@ -1,7 +1,8 @@
1
- export * from "./password.js";
2
1
  export * from "./email-otp.js";
3
2
  export * from "./magic-link.js";
4
3
  export * from "./oauth2.js";
4
+ export * from "./organizations.js";
5
5
  export * from "./passkey.js";
6
+ export * from "./password.js";
7
+ export * from "./stripe.js";
6
8
  export * from "./two-factor.js";
7
- export * from "./organizations.js";
@@ -1,7 +1,8 @@
1
- export * from "./password.js";
2
1
  export * from "./email-otp.js";
3
2
  export * from "./magic-link.js";
4
3
  export * from "./oauth2.js";
4
+ export * from "./organizations.js";
5
5
  export * from "./passkey.js";
6
+ export * from "./password.js";
7
+ export * from "./stripe.js";
6
8
  export * from "./two-factor.js";
7
- export * from "./organizations.js";
@@ -1,18 +1,14 @@
1
+ import { addSeconds, cloneWithout, createId, createToken, deterministicTokenHash } from "../core/utils.js";
2
+ import { ensureFields } from "../core/validators.js";
1
3
  import { AuthError } from "../errors/index.js";
2
4
  import { createIssue } from "../issues/index.js";
3
5
  import { errorOperation, successOperation } from "../types/results.js";
4
- import { addSeconds, createId, createToken, secretHash, secretVerify } from "../core/utils.js";
5
- import { cloneWithout } from "../core/utils.js";
6
- import { ensureFields } from "../core/validators.js";
6
+ const MAGIC_LINK_REQUEST_POLICY = { limit: 3, windowSeconds: 300 };
7
+ const createRateLimitedError = (retryAfterSeconds) => new AuthError("RATE_LIMITED", "Too many requests.", 429, [], retryAfterSeconds === undefined ? undefined : { retryAfterSeconds });
7
8
  export const magicLinkPlugin = (config) => {
8
9
  const ttl = config.tokenTtlSeconds ?? 15 * 60;
9
10
  const verifyToken = async (token, now) => {
10
- const hashed = secretHash(token, "magic-link");
11
- // `secretHash` is salted; this branch should use deterministic hash.
12
- // We keep compatibility by also trying deterministic fallback below.
13
- const deterministicHash = secretHash(token, "deterministic_magic_link");
14
- const candidate = (await config.links.findActiveTokenByHash(deterministicHash)) ??
15
- (await config.links.findActiveTokenByHash(hashed));
11
+ const candidate = await config.links.findActiveTokenByHash(deterministicTokenHash(token, "magic_link"));
16
12
  if (!candidate) {
17
13
  return {
18
14
  ok: false,
@@ -21,15 +17,6 @@ export const magicLinkPlugin = (config) => {
21
17
  ]),
22
18
  };
23
19
  }
24
- // Defensive verify for deterministic hash implementations.
25
- if (!secretVerify(token, candidate.tokenHash) && candidate.tokenHash !== deterministicHash) {
26
- return {
27
- ok: false,
28
- error: new AuthError("MAGIC_LINK_INVALID", "Invalid magic link.", 400, [
29
- createIssue("Invalid token", ["token"]),
30
- ]),
31
- };
32
- }
33
20
  if (candidate.expiresAt.getTime() <= now.getTime()) {
34
21
  return {
35
22
  ok: false,
@@ -55,7 +42,7 @@ export const magicLinkPlugin = (config) => {
55
42
  return {
56
43
  kind: "auth_method",
57
44
  method: "magic_link",
58
- version: "1.0.0",
45
+ version: "2.0.0",
59
46
  supports: {
60
47
  register: true,
61
48
  },
@@ -66,9 +53,16 @@ export const magicLinkPlugin = (config) => {
66
53
  createApi: (ctx) => ({
67
54
  request: async (input, request) => {
68
55
  const email = input.email.trim().toLowerCase();
56
+ if (ctx.adapters.rateLimiter) {
57
+ const policy = ctx.security?.rateLimits?.magicLinkRequest ?? MAGIC_LINK_REQUEST_POLICY;
58
+ const limited = await ctx.adapters.rateLimiter.consume(request?.ip ? `magicLinkRequest:ip:${request.ip}:identity:${email}` : `magicLinkRequest:identity:${email}`, policy.limit, policy.windowSeconds);
59
+ if (!limited.allowed) {
60
+ return errorOperation(createRateLimitedError(limited.retryAfterSeconds));
61
+ }
62
+ }
69
63
  const user = await ctx.adapters.users.findByEmail(email);
70
64
  const rawToken = createToken();
71
- const tokenHash = secretHash(rawToken, "deterministic_magic_link");
65
+ const tokenHash = deterministicTokenHash(rawToken, "magic_link");
72
66
  const expiresAt = addSeconds(ctx.now(), ttl);
73
67
  const token = await config.links.createToken({
74
68
  userId: user?.id,
@@ -118,10 +112,7 @@ export const magicLinkPlugin = (config) => {
118
112
  if (requiredError) {
119
113
  return errorOperation(requiredError);
120
114
  }
121
- const payload = cloneWithout(input, [
122
- "method",
123
- "token",
124
- ]);
115
+ const payload = cloneWithout(input, ["method", "token"]);
125
116
  payload.email = verified.email;
126
117
  payload.emailVerified = true;
127
118
  const user = await ctx.adapters.users.create(payload);
@@ -14,6 +14,12 @@ export type ArcticAuthorizationCodeClient = {
14
14
  } | {
15
15
  validateAuthorizationCode(code: string, codeVerifier: string | null): Promise<OAuth2Tokens>;
16
16
  };
17
+ export type OAuth2AuthorizationCodeExchangeInput = {
18
+ authorizationCode: string;
19
+ codeVerifier?: string;
20
+ redirectUri: string;
21
+ };
22
+ export type OAuth2AuthorizationCodeExchange = (input: OAuth2AuthorizationCodeExchangeInput) => Promise<OAuth2Tokens>;
17
23
  export type OAuth2ResolvedProfile<U extends UserBase, P extends string> = {
18
24
  provider?: P;
19
25
  providerUserId: string;
@@ -22,7 +28,7 @@ export type OAuth2ResolvedProfile<U extends UserBase, P extends string> = {
22
28
  profile?: Partial<U>;
23
29
  };
24
30
  export type OAuth2ProviderConfig<U extends UserBase, P extends string> = {
25
- client: ArcticAuthorizationCodeClient;
31
+ exchangeAuthorizationCode: OAuth2AuthorizationCodeExchange;
26
32
  resolveProfile: (input: {
27
33
  provider: P;
28
34
  tokens: OAuth2Tokens;
@@ -38,4 +44,5 @@ export type OAuth2PluginConfig<U extends UserBase, P extends string, K extends k
38
44
  requiredProfileFields?: readonly K[];
39
45
  pendingProfileTtlSeconds?: number;
40
46
  };
47
+ export declare const arcticAuthorizationCodeExchange: (client: ArcticAuthorizationCodeClient) => OAuth2AuthorizationCodeExchange;
41
48
  export declare const oauth2Plugin: <U extends UserBase, P extends string, K extends keyof U = never>(config: OAuth2PluginConfig<U, P, K>) => AuthMethodPlugin<"oauth2", OAuth2AuthenticateInput<P>, OAuth2AuthenticateInput<P>, U>;
@@ -1,12 +1,14 @@
1
1
  import { ArcticFetchError, OAuth2RequestError } from "arctic";
2
+ import { addSeconds, createId } from "../core/utils.js";
3
+ import { ensureFields } from "../core/validators.js";
2
4
  import { AuthError } from "../errors/index.js";
3
5
  import { createIssue } from "../issues/index.js";
4
6
  import { errorOperation, successOperation } from "../types/results.js";
5
- import { addSeconds, createId } from "../core/utils.js";
6
- import { ensureFields } from "../core/validators.js";
7
- const exchangeAuthorizationCode = async (client, authorizationCode, codeVerifier) => {
8
- const validateAuthorizationCode = client.validateAuthorizationCode;
9
- return validateAuthorizationCode(authorizationCode, codeVerifier);
7
+ export const arcticAuthorizationCodeExchange = (client) => {
8
+ return async ({ authorizationCode, codeVerifier }) => {
9
+ const validateAuthorizationCode = client.validateAuthorizationCode;
10
+ return validateAuthorizationCode(authorizationCode, codeVerifier ?? null);
11
+ };
10
12
  };
11
13
  const getAccessToken = (tokens) => {
12
14
  try {
@@ -30,12 +32,28 @@ export const oauth2Plugin = (config) => {
30
32
  return {
31
33
  kind: "auth_method",
32
34
  method: "oauth2",
33
- version: "1.0.0",
35
+ version: "2.0.0",
34
36
  supports: {
35
37
  register: false,
36
38
  },
37
39
  issueFields: {
38
- authenticate: ["provider", "authorizationCode", "redirectUri", "codeVerifier"],
40
+ authenticate: ["provider", "authorizationCode", "redirectUri", "codeVerifier", "idempotencyKey"],
41
+ },
42
+ completePendingProfile: async (_ctx, { record, user }) => {
43
+ const continuation = record.continuation;
44
+ if (!continuation ||
45
+ typeof continuation.provider !== "string" ||
46
+ typeof continuation.providerUserId !== "string") {
47
+ return errorOperation(new AuthError("PLUGIN_MISCONFIGURED", "Pending OAuth2 profile is missing provider continuation metadata.", 500));
48
+ }
49
+ await config.accounts.linkAccount({
50
+ userId: user.id,
51
+ provider: continuation.provider,
52
+ providerUserId: continuation.providerUserId,
53
+ accessToken: continuation.accessToken,
54
+ refreshToken: continuation.refreshToken,
55
+ });
56
+ return successOperation(undefined);
39
57
  },
40
58
  authenticate: async (ctx, input) => {
41
59
  const providerConfig = config.providers[input.provider];
@@ -44,6 +62,19 @@ export const oauth2Plugin = (config) => {
44
62
  createIssue("Provider disabled", ["provider"]),
45
63
  ]));
46
64
  }
65
+ if (ctx.adapters.idempotency) {
66
+ if (!input.idempotencyKey || input.idempotencyKey.trim() === "") {
67
+ return errorOperation(new AuthError("INVALID_INPUT", "idempotencyKey is required for OAuth2 callbacks.", 400, [
68
+ createIssue("idempotencyKey is required when idempotency is enabled", ["idempotencyKey"]),
69
+ ]));
70
+ }
71
+ const first = await ctx.adapters.idempotency.checkAndSet(`oauth2:${input.provider}:${input.idempotencyKey}`, ctx.security?.oauth2IdempotencyTtlSeconds ?? 300);
72
+ if (!first) {
73
+ return errorOperation(new AuthError("CONFLICT", "Duplicate OAuth2 callback.", 409, [
74
+ createIssue("Duplicate OAuth2 callback", ["idempotencyKey"]),
75
+ ]));
76
+ }
77
+ }
47
78
  if ((providerConfig.pkceRequired ?? true) && !input.codeVerifier) {
48
79
  return errorOperation(new AuthError("INVALID_INPUT", "Missing OAuth2 PKCE code verifier.", 400, [
49
80
  createIssue("codeVerifier is required for this provider", ["codeVerifier"]),
@@ -51,7 +82,11 @@ export const oauth2Plugin = (config) => {
51
82
  }
52
83
  let tokens;
53
84
  try {
54
- tokens = await exchangeAuthorizationCode(providerConfig.client, input.authorizationCode, input.codeVerifier ?? null);
85
+ tokens = await providerConfig.exchangeAuthorizationCode({
86
+ authorizationCode: input.authorizationCode,
87
+ codeVerifier: input.codeVerifier,
88
+ redirectUri: input.redirectUri,
89
+ });
55
90
  }
56
91
  catch (error) {
57
92
  if (error instanceof OAuth2RequestError) {
@@ -60,7 +95,9 @@ export const oauth2Plugin = (config) => {
60
95
  ]));
61
96
  }
62
97
  if (error instanceof ArcticFetchError) {
63
- return errorOperation(new AuthError("OAUTH2_EXCHANGE_FAILED", "OAuth2 provider is unreachable right now.", 502, [createIssue("Provider request failed", ["provider"])]));
98
+ return errorOperation(new AuthError("OAUTH2_EXCHANGE_FAILED", "OAuth2 provider is unreachable right now.", 502, [
99
+ createIssue("Provider request failed", ["provider"]),
100
+ ]));
64
101
  }
65
102
  return errorOperation(new AuthError("OAUTH2_EXCHANGE_FAILED", "OAuth2 code exchange failed.", 502));
66
103
  }
@@ -89,30 +126,35 @@ export const oauth2Plugin = (config) => {
89
126
  return successOperation({ user: linkedUser });
90
127
  }
91
128
  const normalizedEmail = exchanged.email?.trim().toLowerCase();
129
+ if (!normalizedEmail) {
130
+ return errorOperation(new AuthError("INVALID_INPUT", "OAuth2 provider did not return a usable email.", 400, [
131
+ createIssue("Email is required", ["email"]),
132
+ ]));
133
+ }
92
134
  const accessToken = getAccessToken(tokens);
93
135
  const refreshToken = getRefreshToken(tokens);
94
- if (normalizedEmail) {
95
- const existing = await ctx.adapters.users.findByEmail(normalizedEmail);
96
- if (existing) {
97
- await config.accounts.linkAccount({
98
- userId: existing.id,
99
- provider: input.provider,
100
- providerUserId: exchanged.providerUserId,
101
- accessToken,
102
- refreshToken,
103
- });
104
- return successOperation({ user: existing });
105
- }
136
+ const continuation = {
137
+ provider: input.provider,
138
+ providerUserId: exchanged.providerUserId,
139
+ accessToken,
140
+ refreshToken,
141
+ };
142
+ const existing = await ctx.adapters.users.findByEmail(normalizedEmail);
143
+ if (existing) {
144
+ await config.accounts.linkAccount({
145
+ userId: existing.id,
146
+ provider: input.provider,
147
+ providerUserId: exchanged.providerUserId,
148
+ accessToken,
149
+ refreshToken,
150
+ });
151
+ return successOperation({ user: existing });
106
152
  }
107
153
  const profileRecord = {
108
154
  ...exchanged.profile,
155
+ email: normalizedEmail,
156
+ emailVerified: exchanged.emailVerified ?? false,
109
157
  };
110
- if (normalizedEmail) {
111
- profileRecord.email = normalizedEmail;
112
- }
113
- if (exchanged.emailVerified !== undefined) {
114
- profileRecord.emailVerified = exchanged.emailVerified;
115
- }
116
158
  const requiredError = ensureFields(profileRecord, requiredProfileFields.map(String));
117
159
  if (requiredError) {
118
160
  const pending = ctx.adapters.pendingProfiles;
@@ -128,7 +170,8 @@ export const oauth2Plugin = (config) => {
128
170
  sourceMethod: "oauth2",
129
171
  email: normalizedEmail,
130
172
  missingFields,
131
- prefill: exchanged.profile ?? {},
173
+ prefill: profileRecord,
174
+ continuation: continuation,
132
175
  };
133
176
  await pending.create({
134
177
  ...meta,
@@ -137,17 +180,7 @@ export const oauth2Plugin = (config) => {
137
180
  });
138
181
  return errorOperation(new AuthError("PROFILE_COMPLETION_REQUIRED", "Additional profile fields are required.", 400, requiredError.issues, meta), requiredError.issues);
139
182
  }
140
- if (!normalizedEmail) {
141
- return errorOperation(new AuthError("INVALID_INPUT", "OAuth2 provider did not return a usable email.", 400, [
142
- createIssue("Email is required", ["email"]),
143
- ]));
144
- }
145
- const newUserPayload = {
146
- ...profileRecord,
147
- email: normalizedEmail,
148
- emailVerified: exchanged.emailVerified ?? false,
149
- };
150
- const user = await ctx.adapters.users.create(newUserPayload);
183
+ const user = await ctx.adapters.users.create(profileRecord);
151
184
  await config.accounts.linkAccount({
152
185
  userId: user.id,
153
186
  provider: input.provider,
@@ -1,5 +1,5 @@
1
1
  import type { MembershipBase, OrganizationBase, UserBase } from "../types/model.js";
2
- import type { OrganizationsPluginConfig, OrganizationsPluginApi, DomainPlugin } from "../types/plugins.js";
2
+ import type { DomainPlugin, OrganizationsPluginApi, OrganizationsPluginConfig } from "../types/plugins.js";
3
3
  export type OrganizationsPluginOptions<O extends OrganizationBase, Role extends string, M extends MembershipBase<Role>, Permission extends string, Feature extends string, LimitKey extends string, RequiredOrgFields extends keyof O = never> = OrganizationsPluginConfig<O, Role, M, Permission, Feature, LimitKey, RequiredOrgFields> & {
4
4
  inviteBaseUrl: string;
5
5
  inviteTtlSeconds?: number;
@@ -1,7 +1,7 @@
1
+ import { addSeconds, createId, createToken, deterministicTokenHash } from "../core/utils.js";
1
2
  import { AuthError } from "../errors/index.js";
2
3
  import { createIssue } from "../issues/index.js";
3
4
  import { errorOperation, successOperation } from "../types/results.js";
4
- import { addSeconds, createId, createToken, secretHash } from "../core/utils.js";
5
5
  const normalizeEmail = (value) => value.trim().toLowerCase();
6
6
  const findOwnerRoles = (roles) => Object.entries(roles)
7
7
  .filter(([, definition]) => definition.system?.owner)
@@ -49,7 +49,7 @@ export const organizationsPlugin = (config) => {
49
49
  const plugin = {
50
50
  kind: "domain",
51
51
  method: "organizations",
52
- version: "1.0.0",
52
+ version: "2.0.0",
53
53
  __organizationConfig: config,
54
54
  createApi: (ctx) => {
55
55
  const ensureActorMembership = async (userId, organizationId) => {
@@ -75,15 +75,34 @@ export const organizationsPlugin = (config) => {
75
75
  return membershipRes;
76
76
  }
77
77
  const resolved = resolveRole(membershipRes.data.role, config.handlers.roles);
78
+ const stripeApi = ctx.getPluginApi?.("stripe") ?? null;
79
+ let billingEntitlements = {
80
+ features: {},
81
+ limits: {},
82
+ };
83
+ if (stripeApi) {
84
+ const stripeEntitlements = await stripeApi.getEntitlements({
85
+ subject: {
86
+ kind: "organization",
87
+ organizationId,
88
+ },
89
+ });
90
+ if (!stripeEntitlements.ok) {
91
+ return stripeEntitlements;
92
+ }
93
+ billingEntitlements = stripeEntitlements.data;
94
+ }
78
95
  const featureOverrides = await config.handlers.entitlements.getFeatureOverrides(organizationId);
79
96
  const limitOverrides = await config.handlers.entitlements.getLimitOverrides(organizationId);
80
97
  return successOperation({
81
98
  features: {
82
99
  ...resolved.features,
100
+ ...billingEntitlements.features,
83
101
  ...featureOverrides,
84
102
  },
85
103
  limits: {
86
104
  ...resolved.limits,
105
+ ...billingEntitlements.limits,
87
106
  ...limitOverrides,
88
107
  },
89
108
  });
@@ -118,9 +137,7 @@ export const organizationsPlugin = (config) => {
118
137
  });
119
138
  return { organization, membership };
120
139
  };
121
- const data = ctx.adapters.withTransaction
122
- ? await ctx.adapters.withTransaction(run)
123
- : await run();
140
+ const data = ctx.adapters.withTransaction ? await ctx.adapters.withTransaction(run) : await run();
124
141
  return successOperation(data);
125
142
  },
126
143
  inviteMember: async (input, request) => {
@@ -141,7 +158,7 @@ export const organizationsPlugin = (config) => {
141
158
  return errorOperation(new AuthError("ROLE_INVALID", "Unknown role.", 400));
142
159
  }
143
160
  const rawToken = createToken(32);
144
- const tokenHash = secretHash(rawToken, "org-invite");
161
+ const tokenHash = deterministicTokenHash(rawToken, "org_invite");
145
162
  const invite = {
146
163
  id: createId(),
147
164
  organizationId: input.organizationId,
@@ -173,7 +190,7 @@ export const organizationsPlugin = (config) => {
173
190
  });
174
191
  },
175
192
  acceptInvite: async (input) => {
176
- const invite = await config.handlers.invites.findActiveByTokenHash(secretHash(input.token, "org-invite"));
193
+ const invite = await config.handlers.invites.findActiveByTokenHash(deterministicTokenHash(input.token, "org_invite"));
177
194
  if (!invite) {
178
195
  return errorOperation(new AuthError("ORGANIZATION_INVITE_INVALID", "Invalid invite token.", 400));
179
196
  }
@@ -194,9 +211,17 @@ export const organizationsPlugin = (config) => {
194
211
  const existing = await config.handlers.memberships.findByUserAndOrganization(input.userId, invite.organizationId);
195
212
  let membership;
196
213
  if (existing) {
197
- membership = await config.handlers.memberships.setRole(existing.id, invite.role);
214
+ const updatedRole = await config.handlers.memberships.setRole(existing.id, invite.role);
215
+ if (!updatedRole) {
216
+ return errorOperation(new AuthError("MEMBERSHIP_NOT_FOUND", "Membership not found.", 404));
217
+ }
218
+ membership = updatedRole;
198
219
  if (membership.status !== "active") {
199
- membership = await config.handlers.memberships.setStatus(membership.id, "active");
220
+ const updatedStatus = await config.handlers.memberships.setStatus(membership.id, "active");
221
+ if (!updatedStatus) {
222
+ return errorOperation(new AuthError("MEMBERSHIP_NOT_FOUND", "Membership not found.", 404));
223
+ }
224
+ membership = updatedStatus;
200
225
  }
201
226
  }
202
227
  else {
@@ -212,6 +237,27 @@ export const organizationsPlugin = (config) => {
212
237
  membership,
213
238
  });
214
239
  },
240
+ setActiveOrganization: async (input, request) => {
241
+ const session = await ctx.adapters.sessions.findById(input.sessionId);
242
+ if (!session) {
243
+ return errorOperation(new AuthError("SESSION_NOT_FOUND", "Session not found.", 404));
244
+ }
245
+ if (input.organizationId) {
246
+ const memberships = await config.handlers.memberships.listByUser(session.userId);
247
+ const active = memberships.find((membership) => membership.organizationId === input.organizationId && membership.status === "active");
248
+ if (!active) {
249
+ return errorOperation(new AuthError("MEMBERSHIP_FORBIDDEN", "No active membership for organization.", 403));
250
+ }
251
+ }
252
+ const updatedSession = await config.handlers.organizationSessions.setActiveOrganization(input.sessionId, input.organizationId);
253
+ if (!updatedSession) {
254
+ return errorOperation(new AuthError("SESSION_NOT_FOUND", "Session not found.", 404));
255
+ }
256
+ return successOperation({
257
+ sessionId: input.sessionId,
258
+ activeOrganizationId: updatedSession.activeOrganizationId ?? null,
259
+ });
260
+ },
215
261
  setMemberRole: async (input, request) => {
216
262
  const actorUserId = request?.userId;
217
263
  if (!actorUserId) {
@@ -248,6 +294,9 @@ export const organizationsPlugin = (config) => {
248
294
  }
249
295
  }
250
296
  const membership = await config.handlers.memberships.setRole(input.membershipId, input.role);
297
+ if (!membership) {
298
+ return errorOperation(new AuthError("MEMBERSHIP_NOT_FOUND", "Membership not found.", 404));
299
+ }
251
300
  return successOperation({ membership });
252
301
  },
253
302
  listMemberships: async (input) => {