Skip to content

Commit a26c120

Browse files
authored
feat(auth): org operations for composed flows — better-auth idiomatic (#887)
* feat(auth): org operations for composed flows, better-auth idiomatic (#861) - getOrganization: lightweight by slug (better-auth's own surface), unknown translates to null at the plugin boundary - createOrganization gains activate (default true preserves better-auth's create-switches-active behavior; false maps to keepCurrentActiveOrganization for flows that must not disturb the caller's session); deterministic restore via existing setActiveOrganization(null) - skills: the two-call create-or-reuse recipe (checkSlug/getOrganization -> create), replacing hand-built CONFLICT->list->match recovery Issue: #861 * review(auth): 861 fixes — drop misplaced limiter guard, extract shared code insert, tighten tests - handlers/organizations: remove the rate-limit guard (machinery lives in #862; this PR is org ops only) - onboarding service: extract insertOnboardingCode shared by create/rotate, drop the encryptedCode! assertion via explicit narrowing, isStanding naming - tests: activate-semantics test drops the void-bound result; credential-less happy path asserts a resolved personal org instead of Array.isArray Self-review of the series; no behavior change.
1 parent 0326955 commit a26c120

7 files changed

Lines changed: 233 additions & 48 deletions

File tree

‎packages/better-near-auth/skills/auth-plugin/SKILL.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,3 +412,45 @@ Email visibility is a role grant: `email: ["read"]` on a role controls whether i
412412
members see other members' emails. `getContext` returns the caller's effective
413413
permissions as a nullable `permissions` map; routes in any plugin gate on it with
414414
`requirePermission(resource, action)`.
415+
416+
## Idempotent org activation (composed flows)
417+
418+
Create-or-reuse in two calls, no CONFLICT-recovery code:
419+
420+
```ts
421+
const existing = await auth.getOrganization({ organizationSlug: slug }); // null when absent
422+
const org = existing ?? await auth.createOrganization({ name, slug, activate: false });
423+
```
424+
425+
`activate: false` (mapped to better-auth's `keepCurrentActiveOrganization`) leaves the
426+
caller's active organization untouched — composed flows that must not disturb the
427+
session pass it; restore deterministically with `setActiveOrganization({ organizationId: null })`
428+
or re-set the intended org. The default (`activate: true`) preserves better-auth's
429+
create-switches-active behavior. `checkSlug` remains the typed existence probe when
430+
you only need "is this slug taken".
431+
432+
## Abuse-control primitives (levers, not policy)
433+
434+
The plugin ships no admission policy (#857 — policy is the app's job); it ships the levers:
435+
436+
**Rate limits** — configure per operation in `variables.abuseControl.rateLimits`
437+
(`createOrganization`, `createOnboardingCode`, `redeemOnboardingCode`; each
438+
`{ window, max }` in seconds, keyed per caller). Limited calls fail with the
439+
declared `RATE_LIMITED` error carrying `retryAfterSeconds` and `limitType: "requests"`:
440+
441+
```jsonc
442+
"abuseControl": {
443+
"rateLimits": {
444+
"createOrganization": { "window": 3600, "max": 3 }
445+
}
446+
}
447+
```
448+
449+
The limiter is in-memory per plugin instance — multi-instance hosts share no
450+
counters. Absent rules mean unlimited.
451+
452+
**Gate signals** — already resolved in the request context; the intended inputs for
453+
app-side admission decisions: `user.isAnonymous` (anonymous-provisioned accounts),
454+
`user.emailVerified`, account age (`user.createdAt` vs now), and NEAR linkage
455+
(`near.hasNearAccount` / `near.primaryAccountId`). Compose them in your own oRPC
456+
middleware before the auth call — no plugin default policy exists.

‎plugins/auth/src/contract/organizations.contract.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,23 @@ export const organizationRoutes = {
5151
slug: z.string(),
5252
logo: z.string().optional(),
5353
metadata: z.record(z.string(), z.unknown()).optional(),
54+
activate: z.boolean().optional(),
5455
}),
5556
)
5657
.output(organizationInfoSchema.extend({ createdAt: z.date() }))
5758
.errors(Errors),
5859

60+
getOrganization: oc
61+
.route({ method: "GET", path: "/v1/auth/organizations/get" })
62+
.input(
63+
z.object({
64+
organizationId: z.string().optional(),
65+
organizationSlug: z.string().optional(),
66+
}),
67+
)
68+
.output(organizationInfoSchema.nullable())
69+
.errors(Errors),
70+
5971
setActiveOrganization: oc
6072
.route({ method: "POST", path: "/v1/auth/organizations/set-active" })
6173
.input(z.object({ organizationId: z.string().nullable() }))

‎plugins/auth/src/handlers/organizations.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ export function createOrganizationHandlers(
4848
return yield* mapDomainError(organizations.getOrganizationById(input.organizationId));
4949
}),
5050

51+
getOrganization: builder.getOrganization.use(requireAuth).effect(function* ({
52+
input,
53+
context,
54+
}) {
55+
const organizations = yield* OrganizationsService;
56+
return yield* mapDomainError(
57+
organizations.getOrganization(createHeaders(context.reqHeaders), {
58+
organizationId: input?.organizationId,
59+
organizationSlug: input?.organizationSlug,
60+
}),
61+
);
62+
}),
63+
5164
createOrganization: builder.createOrganization.use(requireAuth).effect(function* ({
5265
input,
5366
context,

‎plugins/auth/src/services/onboarding.ts‎

Lines changed: 70 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export const buildOnboardingService = ({
154154
const eventId = input.eventId?.trim() || null;
155155
const eventName = input.eventName?.trim() || null;
156156
const maxUses = input.maxUses ?? DEFAULT_MAX_USES;
157-
const standing = maxUses === 0 && input.expiresAt === null;
157+
const isStanding = maxUses === 0 && input.expiresAt === null;
158158
const nowMs = yield* Clock.currentTimeMillis;
159159
const now = dateFromMillis(nowMs);
160160
const expiresAt = input.expiresAt === null ? null : resolveExpiry(input.expiresAt, nowMs);
@@ -164,10 +164,11 @@ export const buildOnboardingService = ({
164164
});
165165
}
166166

167-
if (standing) {
167+
if (isStanding) {
168168
const existing = yield* Effect.promise(() => findStandingCode(db, organizationId, eventId));
169-
if (existing?.encryptedCode) {
170-
const code = yield* Effect.promise(() => cipher.decrypt(existing.encryptedCode!));
169+
const encrypted = existing?.encryptedCode ?? null;
170+
if (existing !== null && encrypted !== null) {
171+
const code = yield* Effect.promise(() => cipher.decrypt(encrypted));
171172
return { ...toOnboardingSummary(existing), code };
172173
}
173174
}
@@ -177,28 +178,21 @@ export const buildOnboardingService = ({
177178
);
178179
const code = generateCode();
179180
const encryptedCode = yield* Effect.promise(() => cipher.encrypt(code));
180-
const [row] = yield* attemptDb(() =>
181-
db
182-
.insert(schema.onboardingCode)
183-
.values({
184-
id: crypto.randomUUID(),
185-
codeHash: hashCode(code),
186-
encryptedCode,
187-
organizationId,
188-
eventId,
189-
eventName,
190-
teamId,
191-
role: "member",
192-
maxUses,
193-
usedCount: 0,
194-
expiresAt,
195-
createdBy: userId,
196-
createdAt: now,
197-
updatedAt: now,
198-
})
199-
.returning(),
181+
const row = yield* attemptDb(() =>
182+
insertOnboardingCode(db, {
183+
codeHash: hashCode(code),
184+
encryptedCode,
185+
organizationId,
186+
eventId,
187+
eventName,
188+
teamId,
189+
maxUses,
190+
expiresAt,
191+
createdBy: userId,
192+
now,
193+
}),
200194
);
201-
if (!row) {
195+
if (row === null) {
202196
return yield* new CodeCreateFailed({ message: "Failed to create code" });
203197
}
204198

@@ -248,28 +242,21 @@ export const buildOnboardingService = ({
248242
);
249243
const code = generateCode();
250244
const encryptedCode = yield* Effect.promise(() => cipher.encrypt(code));
251-
const [row] = yield* attemptDb(() =>
252-
db
253-
.insert(schema.onboardingCode)
254-
.values({
255-
id: crypto.randomUUID(),
256-
codeHash: hashCode(code),
257-
encryptedCode,
258-
organizationId,
259-
eventId,
260-
eventName: null,
261-
teamId,
262-
role: "member",
263-
maxUses: 0,
264-
usedCount: 0,
265-
expiresAt: null,
266-
createdBy: userId,
267-
createdAt: now,
268-
updatedAt: now,
269-
})
270-
.returning(),
245+
const row = yield* attemptDb(() =>
246+
insertOnboardingCode(db, {
247+
codeHash: hashCode(code),
248+
encryptedCode,
249+
organizationId,
250+
eventId,
251+
eventName: null,
252+
teamId,
253+
maxUses: 0,
254+
expiresAt: null,
255+
createdBy: userId,
256+
now,
257+
}),
271258
);
272-
if (!row) {
259+
if (row === null) {
273260
return yield* new CodeCreateFailed({ message: "Failed to rotate standing code" });
274261
}
275262
return { ...toOnboardingSummary(row), code };
@@ -701,6 +688,43 @@ export const buildOnboardingService = ({
701688
}),
702689
});
703690

691+
async function insertOnboardingCode(
692+
db: Db,
693+
input: {
694+
codeHash: string;
695+
encryptedCode: string;
696+
organizationId: string;
697+
eventId: string | null;
698+
eventName: string | null;
699+
teamId: string;
700+
maxUses: number;
701+
expiresAt: Date | null;
702+
createdBy: string;
703+
now: Date;
704+
},
705+
) {
706+
const [row] = await db
707+
.insert(schema.onboardingCode)
708+
.values({
709+
id: crypto.randomUUID(),
710+
codeHash: input.codeHash,
711+
encryptedCode: input.encryptedCode,
712+
organizationId: input.organizationId,
713+
eventId: input.eventId,
714+
eventName: input.eventName,
715+
teamId: input.teamId,
716+
role: "member",
717+
maxUses: input.maxUses,
718+
usedCount: 0,
719+
expiresAt: input.expiresAt,
720+
createdBy: input.createdBy,
721+
createdAt: input.now,
722+
updatedAt: input.now,
723+
})
724+
.returning();
725+
return row ?? null;
726+
}
727+
704728
async function findStandingCode(db: Db, organizationId: string, eventId: string | null) {
705729
const [row] = await db
706730
.select()

‎plugins/auth/src/services/organizations.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,13 @@ export class OrganizationsService extends Context.Service<
4141
slug: string;
4242
logo?: string | null;
4343
metadata?: Record<string, unknown>;
44+
activate?: boolean;
4445
},
4546
) => Effect.Effect<InferOutput<"createOrganization">, AuthFailure>;
47+
getOrganization: (
48+
headers: Headers,
49+
input: { organizationId?: string; organizationSlug?: string },
50+
) => Effect.Effect<InferOutput<"getOrganization">, AuthFailure>;
4651
setActiveOrganization: (
4752
headers: Headers,
4853
organizationId: string | null,
@@ -192,6 +197,7 @@ export const buildOrganizationsService = ({ auth, db }: { auth: Auth; db: Db })
192197
slug: string;
193198
logo?: string | null;
194199
metadata?: Record<string, unknown>;
200+
activate?: boolean;
195201
},
196202
) =>
197203
attemptAuth(() =>
@@ -202,6 +208,7 @@ export const buildOrganizationsService = ({ auth, db }: { auth: Auth; db: Db })
202208
slug: input.slug,
203209
logo: input.logo,
204210
metadata: input.metadata,
211+
keepCurrentActiveOrganization: input.activate === false,
205212
},
206213
}),
207214
).pipe(
@@ -211,6 +218,25 @@ export const buildOrganizationsService = ({ auth, db }: { auth: Auth; db: Db })
211218
})),
212219
),
213220

221+
getOrganization: (
222+
headers: Headers,
223+
input: { organizationId?: string; organizationSlug?: string },
224+
) =>
225+
attemptAuth(() =>
226+
auth.api.getOrganization({
227+
headers,
228+
query: { organizationId: input.organizationId, organizationSlug: input.organizationSlug },
229+
}),
230+
).pipe(
231+
Effect.catchTag("AuthApiError", (error) => {
232+
if (error.code === "BAD_REQUEST" && error.message === "Organization not found") {
233+
return Effect.succeed(null);
234+
}
235+
return Effect.fail(error);
236+
}),
237+
Effect.map((result: any) => (result ? toOrganizationInfo(result) : null)),
238+
),
239+
214240
setActiveOrganization: (headers: Headers, organizationId: string | null) =>
215241
attemptAuth(() =>
216242
auth.api.setActiveOrganization({

‎plugins/auth/tests/integration/credential-less-client.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,6 @@ describe("credential-less in-process client (the #860 repro)", () => {
5656
context: { reqHeaders: user.reqHeaders },
5757
});
5858

59-
expect(Array.isArray(organizations)).toBe(true);
59+
expect(organizations.length).toBeGreaterThan(0);
6060
});
6161
});

‎plugins/auth/tests/integration/organizations.test.ts‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { eq } from "drizzle-orm";
1+
import { desc, eq } from "drizzle-orm";
22
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
33
import * as schema from "../../src/db/schema";
44
import {
@@ -364,4 +364,72 @@ describe("organization handlers", () => {
364364
expect(result.daoAccountId).toBeNull();
365365
});
366366
});
367+
368+
describe("getOrganization", () => {
369+
it("returns lightweight org info by slug and null for unknown", async () => {
370+
const user = await createTestUser(services.services);
371+
const org = await createTestOrg(services.services, user.userId, {
372+
name: "Slug Org",
373+
slug: `slug-org-${crypto.randomUUID().slice(0, 8)}`,
374+
});
375+
const handlers = createTestHandlers(services.services);
376+
377+
const found = await handlers.organizations.getOrganization({
378+
input: { organizationSlug: org.slug },
379+
context: { reqHeaders: user.reqHeaders },
380+
});
381+
expect(found?.id).toBe(org.id);
382+
expect(found?.slug).toBe(org.slug);
383+
384+
const missing = await handlers.organizations.getOrganization({
385+
input: { organizationSlug: `nope-${crypto.randomUUID().slice(0, 8)}` },
386+
context: { reqHeaders: user.reqHeaders },
387+
});
388+
expect(missing).toBeNull();
389+
});
390+
});
391+
392+
describe("createOrganization activate semantics", () => {
393+
it("defaults to switching the active organization; activate:false keeps it", async () => {
394+
const user = await createTestUser(services.services);
395+
const first = await createTestOrg(services.services, user.userId, {
396+
slug: `act-a-${crypto.randomUUID().slice(0, 8)}`,
397+
});
398+
const handlers = createTestHandlers(services.services);
399+
400+
await handlers.organizations.setActiveOrganization({
401+
input: { organizationId: first.id },
402+
context: { reqHeaders: user.reqHeaders },
403+
});
404+
405+
await handlers.organizations.createOrganization({
406+
input: {
407+
name: "Side Org",
408+
slug: `act-b-${crypto.randomUUID().slice(0, 8)}`,
409+
activate: false,
410+
},
411+
context: { reqHeaders: user.reqHeaders },
412+
});
413+
414+
const sessionAfterKeep = await services.services.db.query.session.findFirst({
415+
where: eq(schema.session.userId, user.userId),
416+
orderBy: (s) => desc(s.createdAt),
417+
});
418+
expect(sessionAfterKeep?.activeOrganizationId).toBe(first.id);
419+
420+
const switched = await handlers.organizations.createOrganization({
421+
input: {
422+
name: "Main Org",
423+
slug: `act-c-${crypto.randomUUID().slice(0, 8)}`,
424+
},
425+
context: { reqHeaders: user.reqHeaders },
426+
});
427+
428+
const sessionAfterSwitch = await services.services.db.query.session.findFirst({
429+
where: eq(schema.session.userId, user.userId),
430+
orderBy: (s) => desc(s.createdAt),
431+
});
432+
expect(sessionAfterSwitch?.activeOrganizationId).toBe(switched.id);
433+
});
434+
});
367435
});

0 commit comments

Comments
 (0)