@arcote.tech/arc-auth 0.4.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.
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@arcote.tech/arc-auth",
3
+ "type": "module",
4
+ "version": "0.4.1",
5
+ "private": false,
6
+ "description": "Reusable authentication module for Arc framework — aggregate-based auth with factory pattern",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "scripts": {
10
+ "type-check": "tsc --noEmit"
11
+ },
12
+ "peerDependencies": {
13
+ "@arcote.tech/arc": "workspace:*",
14
+ "react": "^18.0.0 || ^19.0.0",
15
+ "typescript": "^5.0.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/bun": "latest"
19
+ }
20
+ }
@@ -0,0 +1,301 @@
1
+ /// <reference path="../arc.d.ts" />
2
+ import {
3
+ aggregate,
4
+ boolean,
5
+ date,
6
+ string,
7
+ type $type,
8
+ type ArcRawShape,
9
+ } from "@arcote.tech/arc";
10
+ import type { AccountId } from "../ids/account";
11
+ import type { Token } from "../tokens/token";
12
+
13
+ /**
14
+ * Password helpers — server-only, tree-shaken on client.
15
+ */
16
+ async function verifyPassword(
17
+ password: string,
18
+ hash: string,
19
+ ): Promise<boolean> {
20
+ return await Bun.password.verify(password, hash);
21
+ }
22
+
23
+ async function hashPassword(password: string): Promise<string> {
24
+ return await Bun.password.hash(password);
25
+ }
26
+
27
+ export type AccountAggregateData<CustomFields extends ArcRawShape> = {
28
+ name: string;
29
+ accountId: AccountId;
30
+ token: Token;
31
+ customFields: CustomFields;
32
+ };
33
+
34
+ export const createAccountAggregate = <
35
+ const CustomFields extends ArcRawShape,
36
+ const Data extends AccountAggregateData<CustomFields>,
37
+ >(
38
+ data: Data,
39
+ ) => {
40
+ const { accountId, token, customFields } = data;
41
+
42
+ return (
43
+ aggregate(`${data.name}Accounts`, accountId, {
44
+ email: string().email(),
45
+ isEmailVerified: boolean(),
46
+ passwordHash: string().optional(),
47
+ authMethod: string(),
48
+ registeredAt: date(),
49
+ lastSignedInAt: date().optional(),
50
+ ...customFields,
51
+ })
52
+ // --- Public Events ---
53
+
54
+ .publicEvent(
55
+ "accountRegistered",
56
+ {
57
+ accountId,
58
+ email: string().email(),
59
+ passwordHash: string(),
60
+ ...customFields,
61
+ },
62
+ async (ctx, event) => {
63
+ const {
64
+ accountId: id,
65
+ email,
66
+ passwordHash,
67
+ ...customData
68
+ } = event.payload as any;
69
+ await ctx.set(id, {
70
+ email,
71
+ isEmailVerified: false,
72
+ passwordHash,
73
+ authMethod: "email",
74
+ registeredAt: event.createdAt,
75
+ lastSignedInAt: null,
76
+ ...customData,
77
+ } as any);
78
+ },
79
+ )
80
+
81
+ .publicEvent(
82
+ "accountRegisteredViaOAuth",
83
+ {
84
+ accountId,
85
+ email: string().email(),
86
+ provider: string(),
87
+ providerUserId: string(),
88
+ ...customFields,
89
+ },
90
+ async (ctx, event) => {
91
+ const {
92
+ accountId: id,
93
+ email,
94
+ provider: _provider,
95
+ providerUserId: _providerUserId,
96
+ ...customData
97
+ } = event.payload as any;
98
+ await ctx.set(id, {
99
+ email,
100
+ isEmailVerified: true,
101
+ passwordHash: null,
102
+ authMethod: "oauth",
103
+ registeredAt: event.createdAt,
104
+ lastSignedInAt: event.createdAt,
105
+ ...customData,
106
+ } as any);
107
+ },
108
+ )
109
+
110
+ .publicEvent(
111
+ "signedIn",
112
+ { accountId, email: string().email() },
113
+ async (ctx, event) => {
114
+ await ctx.modify(
115
+ event.payload.accountId as any,
116
+ {
117
+ lastSignedInAt: event.createdAt,
118
+ } as any,
119
+ );
120
+ },
121
+ )
122
+
123
+ .publicEvent("emailVerified", { accountId }, async (ctx, event) => {
124
+ await ctx.modify(
125
+ event.payload.accountId as any,
126
+ {
127
+ isEmailVerified: true,
128
+ } as any,
129
+ );
130
+ })
131
+
132
+ // --- Mutate Methods ---
133
+
134
+ /**
135
+ * register — server-only. Hashes password, emits accountRegistered.
136
+ */
137
+ .mutateMethod(
138
+ "register",
139
+ {
140
+ params: {
141
+ email: string().email(),
142
+ password: string().minLength(6).maxLength(32),
143
+ ...customFields,
144
+ },
145
+ },
146
+ ONLY_SERVER &&
147
+ (async (ctx: any, params: any) => {
148
+ const existing = await ctx.$query.findOne({ email: params.email });
149
+ if (existing) {
150
+ return { error: "EMAIL_ALREADY_TAKEN" as const };
151
+ }
152
+
153
+ const id = accountId.generate();
154
+ const pwHash = await hashPassword(params.password);
155
+ const { email, password: _pw, ...custom } = params;
156
+
157
+ await ctx.accountRegistered.emit({
158
+ accountId: id,
159
+ email,
160
+ passwordHash: pwHash,
161
+ ...custom,
162
+ });
163
+
164
+ return { accountId: id };
165
+ }),
166
+ )
167
+
168
+ /**
169
+ * signIn — server-only. Verifies password, checks email verification, returns JWT.
170
+ */
171
+ .mutateMethod(
172
+ "signIn",
173
+ {
174
+ params: {
175
+ email: string().email(),
176
+ password: string().minLength(6).maxLength(32),
177
+ },
178
+ },
179
+ ONLY_SERVER &&
180
+ (async (ctx: any, params: any) => {
181
+ const account = await ctx.$query.findOne({ email: params.email });
182
+
183
+ if (!account) {
184
+ return { error: "INVALID_EMAIL_OR_PASSWORD" as const };
185
+ }
186
+
187
+ const isValid = await verifyPassword(
188
+ params.password,
189
+ account.passwordHash,
190
+ );
191
+ if (!isValid) {
192
+ return { error: "INVALID_EMAIL_OR_PASSWORD" as const };
193
+ }
194
+
195
+ if (!account.isEmailVerified) {
196
+ return {
197
+ error: "EMAIL_NOT_VERIFIED" as const,
198
+ email: params.email,
199
+ };
200
+ }
201
+
202
+ const jwtToken = token.generateJWT({ accountId: account._id });
203
+
204
+ await ctx.signedIn.emit({
205
+ accountId: account._id,
206
+ email: params.email,
207
+ });
208
+
209
+ return { token: jwtToken };
210
+ }),
211
+ )
212
+
213
+ /**
214
+ * registerViaOAuth — server-only. Creates account from OAuth provider data.
215
+ */
216
+ .mutateMethod(
217
+ "registerViaOAuth",
218
+ {
219
+ params: {
220
+ email: string().email(),
221
+ provider: string(),
222
+ providerUserId: string(),
223
+ ...customFields,
224
+ },
225
+ result: {} as
226
+ | { accountId: $type<typeof accountId> }
227
+ | {
228
+ error: "EMAIL_ALREADY_TAKEN";
229
+ accountId: $type<typeof accountId>;
230
+ },
231
+ },
232
+ ONLY_SERVER &&
233
+ (async (ctx: any, params: any) => {
234
+ const existing = await ctx.$query.findOne({ email: params.email });
235
+ if (existing) {
236
+ return {
237
+ error: "EMAIL_ALREADY_TAKEN" as const,
238
+ accountId: existing._id,
239
+ };
240
+ }
241
+
242
+ const id = accountId.generate();
243
+ const { email, provider, providerUserId, ...custom } = params;
244
+
245
+ await ctx.accountRegisteredViaOAuth.emit({
246
+ accountId: id,
247
+ email,
248
+ provider,
249
+ providerUserId,
250
+ ...custom,
251
+ });
252
+
253
+ return { accountId: id };
254
+ }),
255
+ )
256
+
257
+ /**
258
+ * signInViaOAuth — server-only. Signs in via OAuth (no password check).
259
+ * Identity already verified by the OAuth provider.
260
+ */
261
+ .mutateMethod(
262
+ "signInViaOAuth",
263
+ {
264
+ params: {
265
+ email: string().email(),
266
+ provider: string(),
267
+ providerUserId: string(),
268
+ },
269
+ result: {} as { token: string } | { error: "ACCOUNT_NOT_FOUND" },
270
+ },
271
+ ONLY_SERVER &&
272
+ (async (ctx: any, params: any) => {
273
+ const account = await ctx.$query.findOne({ email: params.email });
274
+
275
+ if (!account) {
276
+ return { error: "ACCOUNT_NOT_FOUND" as const };
277
+ }
278
+
279
+ const jwtToken = token.generateJWT({ accountId: account._id });
280
+
281
+ await ctx.signedIn.emit({
282
+ accountId: account._id,
283
+ email: params.email,
284
+ });
285
+
286
+ return { token: jwtToken };
287
+ }),
288
+ )
289
+
290
+ .protectBy(token, (params: any) => ({ _id: params.accountId }))
291
+ .clientQuery("getAll", async (ctx) => ctx.$query.find({}))
292
+ .clientQuery("getMe", async (ctx) => ctx.$query.findOne({}))
293
+ .build()
294
+ );
295
+ };
296
+
297
+ export type AccountAggregate<
298
+ CustomFields extends ArcRawShape = ArcRawShape,
299
+ Data extends AccountAggregateData<CustomFields> =
300
+ AccountAggregateData<CustomFields>,
301
+ > = ReturnType<typeof createAccountAggregate<CustomFields, Data>>;
@@ -0,0 +1,131 @@
1
+ import { aggregate, date, string } from "@arcote.tech/arc";
2
+ import type { OAuthIdentityId } from "../ids/oauth-identity";
3
+ import type { AccountId } from "../ids/account";
4
+
5
+ export type OAuthIdentityAggregateData = {
6
+ name: string;
7
+ oauthIdentityId: OAuthIdentityId;
8
+ accountId: AccountId;
9
+ };
10
+
11
+ export const createOAuthIdentityAggregate = <
12
+ const Data extends OAuthIdentityAggregateData,
13
+ >(
14
+ data: Data,
15
+ ) => {
16
+ const { oauthIdentityId, accountId } = data;
17
+
18
+ return aggregate(`${data.name}OAuthIdentities`, oauthIdentityId, {
19
+ accountId,
20
+ provider: string(),
21
+ providerUserId: string(),
22
+ providerEmail: string().email(),
23
+ linkedAt: date(),
24
+ lastUsedAt: date().optional(),
25
+ })
26
+ .publicEvent(
27
+ "oauthIdentityLinked",
28
+ {
29
+ oauthIdentityId,
30
+ accountId,
31
+ provider: string(),
32
+ providerUserId: string(),
33
+ providerEmail: string().email(),
34
+ },
35
+ async (ctx, event) => {
36
+ const {
37
+ oauthIdentityId: id,
38
+ accountId: accId,
39
+ provider,
40
+ providerUserId,
41
+ providerEmail,
42
+ } = event.payload as any;
43
+ await ctx.set(id, {
44
+ accountId: accId,
45
+ provider,
46
+ providerUserId,
47
+ providerEmail,
48
+ linkedAt: event.createdAt,
49
+ lastUsedAt: null,
50
+ } as any);
51
+ },
52
+ )
53
+
54
+ .publicEvent(
55
+ "oauthIdentityUsed",
56
+ { oauthIdentityId },
57
+ async (ctx, event) => {
58
+ await ctx.modify(event.payload.oauthIdentityId as any, {
59
+ lastUsedAt: event.createdAt,
60
+ } as any);
61
+ },
62
+ )
63
+
64
+ .publicEvent(
65
+ "oauthIdentityUnlinked",
66
+ { oauthIdentityId },
67
+ async (ctx, event) => {
68
+ await ctx.remove(event.payload.oauthIdentityId as any);
69
+ },
70
+ )
71
+
72
+ .mutateMethod(
73
+ "linkIdentity",
74
+ {
75
+ params: {
76
+ accountId,
77
+ provider: string(),
78
+ providerUserId: string(),
79
+ providerEmail: string().email(),
80
+ },
81
+ },
82
+ ONLY_SERVER &&
83
+ (async (ctx: any, params: any) => {
84
+ // Check for duplicate (same provider + providerUserId)
85
+ const existing = await ctx.$query.findOne({
86
+ provider: params.provider,
87
+ providerUserId: params.providerUserId,
88
+ });
89
+ if (existing) {
90
+ return { error: "OAUTH_IDENTITY_ALREADY_LINKED" as const };
91
+ }
92
+
93
+ const id = oauthIdentityId.generate();
94
+
95
+ await ctx.oauthIdentityLinked.emit({
96
+ oauthIdentityId: id,
97
+ accountId: params.accountId,
98
+ provider: params.provider,
99
+ providerUserId: params.providerUserId,
100
+ providerEmail: params.providerEmail,
101
+ });
102
+
103
+ return { oauthIdentityId: id };
104
+ }),
105
+ )
106
+
107
+ .mutateMethod(
108
+ "markUsed",
109
+ { params: { oauthIdentityId } },
110
+ ONLY_SERVER &&
111
+ (async (ctx: any, params: any) => {
112
+ await ctx.oauthIdentityUsed.emit({
113
+ oauthIdentityId: params.oauthIdentityId,
114
+ });
115
+ }),
116
+ )
117
+ .clientQuery("getAll", async (ctx) => ctx.$query.find({}))
118
+ .clientQuery(
119
+ "findByProvider",
120
+ async (ctx, params: { provider: string; providerUserId: string }) =>
121
+ ctx.$query.findOne({
122
+ provider: params.provider,
123
+ providerUserId: params.providerUserId,
124
+ }),
125
+ )
126
+ .build();
127
+ };
128
+
129
+ export type OAuthIdentityAggregate<
130
+ Data extends OAuthIdentityAggregateData = OAuthIdentityAggregateData,
131
+ > = ReturnType<typeof createOAuthIdentityAggregate<Data>>;
package/src/arc.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ declare const BROWSER: boolean;
2
+ declare const NOT_ON_BROWSER: boolean;
3
+ declare const ONLY_BROWSER: boolean;
4
+ declare const SERVER: boolean;
5
+ declare const NOT_ON_SERVER: boolean;
6
+ declare const ONLY_SERVER: boolean;
7
+
8
+ // Bun runtime API — available on server only, tree-shaken on browser
9
+ declare namespace Bun {
10
+ const password: {
11
+ verify(password: string, hash: string): Promise<boolean>;
12
+ hash(password: string): Promise<string>;
13
+ };
14
+ }
@@ -0,0 +1,132 @@
1
+ import {
2
+ aggregateContextElement,
3
+ context,
4
+ route,
5
+ type ArcAggregateElement,
6
+ type ArcContextElement,
7
+ type ArcRawShape,
8
+ type AggregateConstructorAny,
9
+ } from "@arcote.tech/arc";
10
+ import { createAccountAggregate } from "./aggregates/account";
11
+ import { createOAuthIdentityAggregate } from "./aggregates/oauth-identity";
12
+ import { createAccountId } from "./ids/account";
13
+ import { createOAuthIdentityId } from "./ids/oauth-identity";
14
+ import { createToken } from "./tokens/token";
15
+ import { createOAuthRoutes } from "./routes/oauth-routes";
16
+ import type { OAuthProvidersConfig } from "./providers/types";
17
+
18
+ export class AuthBuilder<
19
+ AccId,
20
+ Tok,
21
+ Account extends AggregateConstructorAny,
22
+ AccountEl extends ArcAggregateElement<Account>,
23
+ OAuthIdentity extends AggregateConstructorAny | undefined,
24
+ EnabledProviders extends string[],
25
+ Elements extends ArcContextElement<any>[],
26
+ > {
27
+ constructor(
28
+ private readonly _name: string,
29
+ readonly accountId: AccId,
30
+ readonly token: Tok,
31
+ readonly Account: Account,
32
+ readonly accountElement: AccountEl,
33
+ readonly OAuthIdentity: OAuthIdentity,
34
+ readonly enabledProviders: EnabledProviders,
35
+ readonly elements: Elements,
36
+ ) {}
37
+
38
+ useOAuth(config: {
39
+ providers?: OAuthProvidersConfig;
40
+ baseUrl?: string;
41
+ }) {
42
+ const oauthIdentityId = createOAuthIdentityId({ name: this._name });
43
+ const OAuthIdentity = createOAuthIdentityAggregate({
44
+ name: this._name,
45
+ oauthIdentityId,
46
+ accountId: this.accountId as any,
47
+ });
48
+ const oauthIdentityElement = aggregateContextElement(OAuthIdentity);
49
+
50
+ const hasProviders = config.providers && config.baseUrl;
51
+ const routes = hasProviders
52
+ ? createOAuthRoutes({
53
+ providers: config.providers!,
54
+ baseUrl: config.baseUrl!,
55
+ token: this.token as any,
56
+ accountElement: this.accountElement,
57
+ oauthIdentityElement,
58
+ })
59
+ : null;
60
+
61
+ const oauthStart =
62
+ routes?.oauthStart ??
63
+ route("oauthStart")
64
+ .path("/auth/oauth/:provider/start")
65
+ .public()
66
+ .handle({});
67
+ const oauthCallback =
68
+ routes?.oauthCallback ??
69
+ route("oauthCallback")
70
+ .path("/auth/oauth/:provider/callback")
71
+ .public()
72
+ .handle({});
73
+
74
+ return new AuthBuilder(
75
+ this._name,
76
+ this.accountId,
77
+ this.token,
78
+ this.Account,
79
+ this.accountElement,
80
+ OAuthIdentity,
81
+ routes?.enabledProviders ?? ([] as string[]),
82
+ [
83
+ this.accountElement,
84
+ oauthIdentityElement,
85
+ oauthStart,
86
+ oauthCallback,
87
+ ] as const,
88
+ );
89
+ }
90
+
91
+ build() {
92
+ return {
93
+ context: context(this.elements),
94
+ accountId: this.accountId,
95
+ token: this.token,
96
+ Account: this.Account,
97
+ OAuthIdentity: this.OAuthIdentity,
98
+ enabledProviders: this.enabledProviders,
99
+ };
100
+ }
101
+ }
102
+
103
+ export function auth<
104
+ const Name extends string,
105
+ const CustomFields extends ArcRawShape,
106
+ const Secret extends string | undefined,
107
+ >(config: {
108
+ name: Name;
109
+ customFields: CustomFields;
110
+ secret: Secret;
111
+ }) {
112
+ const accountId = createAccountId({ name: config.name });
113
+ const token = createToken({ name: config.name, secret: config.secret });
114
+ const Account = createAccountAggregate({
115
+ name: config.name,
116
+ accountId,
117
+ token,
118
+ customFields: config.customFields,
119
+ });
120
+ const accountElement = aggregateContextElement(Account);
121
+
122
+ return new AuthBuilder(
123
+ config.name,
124
+ accountId,
125
+ token,
126
+ Account,
127
+ accountElement,
128
+ undefined,
129
+ [] as string[],
130
+ [accountElement] as const,
131
+ );
132
+ }
@@ -0,0 +1,13 @@
1
+ import { id } from "@arcote.tech/arc";
2
+
3
+ export type AccountIdData = {
4
+ name: string;
5
+ };
6
+
7
+ export const createAccountId = <const Data extends AccountIdData>(
8
+ data: Readonly<Data>,
9
+ ) => id(`${data.name}Account`);
10
+
11
+ export type AccountId<Data extends AccountIdData = AccountIdData> = ReturnType<
12
+ typeof createAccountId<Data>
13
+ >;
@@ -0,0 +1,15 @@
1
+ import { id } from "@arcote.tech/arc";
2
+
3
+ export type OAuthIdentityIdData = {
4
+ name: string;
5
+ };
6
+
7
+ export const createOAuthIdentityId = <
8
+ const Data extends OAuthIdentityIdData,
9
+ >(
10
+ data: Readonly<Data>,
11
+ ) => id(`${data.name}OAuthIdentity`);
12
+
13
+ export type OAuthIdentityId<
14
+ Data extends OAuthIdentityIdData = OAuthIdentityIdData,
15
+ > = ReturnType<typeof createOAuthIdentityId<Data>>;