@protobase/server 0.25.12 → 0.26.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/dist/admin-options.d.ts +7 -0
- package/dist/better-auth/app-tokens.js +2 -2
- package/dist/better-auth/authenticator.js +1 -1
- package/dist/better-auth/create-auth.d.ts +121 -2
- package/dist/better-auth/create-auth.js +70 -51
- package/dist/better-auth/email-confirmed.js +31 -0
- package/dist/better-auth/organizations/accept-invitation.js +1 -1
- package/dist/better-auth/organizations/app-roles.js +1 -1
- package/dist/better-auth/organizations/options.js +1 -1
- package/dist/better-auth/organizations/organization-plugin.js +1 -1
- package/dist/better-auth/organizations/session-organization.js +1 -1
- package/dist/better-auth/passkey-plugin.js +1 -1
- package/dist/better-auth/policy-plugin.js +27 -12
- package/dist/better-auth/roles/active-role.js +1 -1
- package/dist/better-auth/sign-up/sign-up-account.js +26 -0
- package/dist/better-auth/sign-up/sign-up-plugin.js +6 -14
- package/dist/better-auth/staff-plugin.js +1 -1
- package/dist/better-auth/staff-sign-in-endpoints.js +3 -3
- package/dist/better-auth/status-route.js +1 -1
- package/dist/better-auth/user-table.d.ts +1 -0
- package/dist/better-auth/user-table.js +1 -0
- package/dist/better-auth/users/user-endpoints.js +1 -1
- package/dist/create-admin.d.ts +10 -1
- package/dist/create-admin.js +26 -2
- package/dist/gotrue/access-tokens.js +15 -0
- package/dist/gotrue/auth-codes.js +33 -0
- package/dist/gotrue/bearer.js +22 -0
- package/dist/gotrue/deps.js +8 -0
- package/dist/gotrue/email-code-routes.js +121 -0
- package/dist/gotrue/errors.js +52 -0
- package/dist/gotrue/forward.js +45 -0
- package/dist/gotrue/keys.js +14 -0
- package/dist/gotrue/pending-sign-up.js +26 -0
- package/dist/gotrue/provider-routes.js +79 -0
- package/dist/gotrue/request-body.js +22 -0
- package/dist/gotrue/routes.js +32 -0
- package/dist/gotrue/session-json.js +34 -0
- package/dist/gotrue/sessions-plugin.js +83 -0
- package/dist/gotrue/token-route.js +42 -0
- package/dist/gotrue/user-json.js +46 -0
- package/dist/gotrue/user-routes.js +99 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -7
- package/dist/live/notices-sql.js +4 -2
- package/dist/realtime/change-checker.js +102 -0
- package/dist/realtime/change-filter.js +106 -0
- package/dist/realtime/change-hub.js +100 -0
- package/dist/realtime/change-payload.js +31 -0
- package/dist/realtime/phoenix-message.js +63 -0
- package/dist/realtime/postgres-changes.js +62 -0
- package/dist/realtime/realtime-app.js +31 -0
- package/dist/realtime/realtime-routes.js +47 -0
- package/dist/realtime/realtime-socket.js +135 -0
- package/dist/rest/caller-settings.js +27 -0
- package/package.json +10 -5
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { goTrueAudience } from "./keys.js";
|
|
2
|
+
import { appMetadata, userMetadata } from "./user-json.js";
|
|
3
|
+
//#region src/gotrue/session-json.ts
|
|
4
|
+
/** How long an access token works, in seconds: an hour, as Supabase's do by default. */
|
|
5
|
+
const accessTokenSeconds = 3600;
|
|
6
|
+
/**
|
|
7
|
+
* The claims of a signed-in user's access token, Supabase's: `role` is the database role, `authenticated`, never the
|
|
8
|
+
* user's roles in the app, which Protobase's own tokens carry. `aal1`, as nobody signs in with a second factor here.
|
|
9
|
+
*/
|
|
10
|
+
const accessClaims = ({ user, accounts, sessionId, issuer, now }) => ({
|
|
11
|
+
iss: issuer,
|
|
12
|
+
sub: user.id,
|
|
13
|
+
aud: goTrueAudience,
|
|
14
|
+
iat: now,
|
|
15
|
+
exp: now + accessTokenSeconds,
|
|
16
|
+
email: user.email,
|
|
17
|
+
app_metadata: appMetadata(accounts),
|
|
18
|
+
user_metadata: userMetadata(user.metadata),
|
|
19
|
+
role: "authenticated",
|
|
20
|
+
aal: "aal1",
|
|
21
|
+
session_id: sessionId,
|
|
22
|
+
is_anonymous: false
|
|
23
|
+
});
|
|
24
|
+
/** The session GoTrue answers a sign-in or a refresh with. */
|
|
25
|
+
const goTrueSession = ({ accessToken, refreshToken, issuedAt, user }) => ({
|
|
26
|
+
access_token: accessToken,
|
|
27
|
+
token_type: "bearer",
|
|
28
|
+
expires_in: accessTokenSeconds,
|
|
29
|
+
expires_at: issuedAt + accessTokenSeconds,
|
|
30
|
+
refresh_token: refreshToken,
|
|
31
|
+
user
|
|
32
|
+
});
|
|
33
|
+
//#endregion
|
|
34
|
+
export { accessClaims, accessTokenSeconds, goTrueSession };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { goTrueAlgorithm } from "./keys.js";
|
|
2
|
+
import { tokenRefusal } from "../better-auth/policy-plugin.js";
|
|
3
|
+
import { goTrueUser } from "./user-json.js";
|
|
4
|
+
import { accessClaims, goTrueSession } from "./session-json.js";
|
|
5
|
+
import * as z$1 from "zod";
|
|
6
|
+
import { APIError, createAuthEndpoint } from "better-auth/api";
|
|
7
|
+
import { createJwk, signJWT } from "better-auth/plugins";
|
|
8
|
+
import { generateRandomString } from "better-auth/crypto";
|
|
9
|
+
//#region src/gotrue/sessions-plugin.ts
|
|
10
|
+
const refreshTokenNotFound = () => new APIError("BAD_REQUEST", {
|
|
11
|
+
code: "REFRESH_TOKEN_NOT_FOUND",
|
|
12
|
+
message: "Invalid Refresh Token: Refresh Token Not Found"
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* The sessions of the GoTrue-compatible endpoints, which are Better Auth's own: a session's token is the refresh token,
|
|
16
|
+
* so signing out anywhere, a ban or a revoked session ends it, and refreshing gives the session a new token, so each
|
|
17
|
+
* refresh token works once. Server-side endpoints only, for `goTrueRoutes`.
|
|
18
|
+
*/
|
|
19
|
+
const goTrueSessionsPlugin = ({ issuer, jwt, policy }) => {
|
|
20
|
+
let primaryKeyMade = false;
|
|
21
|
+
const makePrimaryKey = async (ctx) => {
|
|
22
|
+
if (primaryKeyMade) return;
|
|
23
|
+
if (!(await ctx.context.adapter.findMany({ model: "jwks" })).some((key) => key.alg === null || key.alg === void 0 || key.alg === "EdDSA")) await createJwk(ctx, jwt);
|
|
24
|
+
primaryKeyMade = true;
|
|
25
|
+
};
|
|
26
|
+
const sessionOf = async (ctx, sessionToken) => {
|
|
27
|
+
const found = await ctx.context.internalAdapter.findSession(sessionToken);
|
|
28
|
+
if (!found) throw refreshTokenNotFound();
|
|
29
|
+
const user = found.user;
|
|
30
|
+
const refusal = await tokenRefusal(ctx.context, user, policy);
|
|
31
|
+
if (refusal) throw new APIError("FORBIDDEN", refusal);
|
|
32
|
+
const accounts = await ctx.context.internalAdapter.findAccounts(user.id);
|
|
33
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
34
|
+
await makePrimaryKey(ctx);
|
|
35
|
+
const accessToken = await signJWT(ctx, {
|
|
36
|
+
options: jwt,
|
|
37
|
+
payload: accessClaims({
|
|
38
|
+
user,
|
|
39
|
+
accounts,
|
|
40
|
+
sessionId: found.session.id,
|
|
41
|
+
issuer,
|
|
42
|
+
now
|
|
43
|
+
}),
|
|
44
|
+
signingAlgorithm: goTrueAlgorithm
|
|
45
|
+
});
|
|
46
|
+
return goTrueSession({
|
|
47
|
+
accessToken,
|
|
48
|
+
refreshToken: sessionToken,
|
|
49
|
+
issuedAt: now,
|
|
50
|
+
user: goTrueUser(user, accounts)
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
id: "protobase-gotrue-sessions",
|
|
55
|
+
endpoints: {
|
|
56
|
+
/** The GoTrue session of the Better Auth session with `sessionToken`: an access token for it, and the token to refresh it with. */
|
|
57
|
+
goTrueSession: createAuthEndpoint.serverOnly({
|
|
58
|
+
method: "POST",
|
|
59
|
+
body: z$1.object({ sessionToken: z$1.string().min(1) })
|
|
60
|
+
}, async (ctx) => ctx.json(await sessionOf(ctx, ctx.body.sessionToken))),
|
|
61
|
+
/**
|
|
62
|
+
* Refreshes the session whose token is `refreshToken`: gives it a new token and a new lifetime, and answers its
|
|
63
|
+
* GoTrue session. Of two refreshes with the same token, only one finds it.
|
|
64
|
+
*/
|
|
65
|
+
goTrueRefresh: createAuthEndpoint.serverOnly({
|
|
66
|
+
method: "POST",
|
|
67
|
+
body: z$1.object({ refreshToken: z$1.string().min(1) })
|
|
68
|
+
}, async (ctx) => {
|
|
69
|
+
const { internalAdapter, sessionConfig } = ctx.context;
|
|
70
|
+
const found = await internalAdapter.findSession(ctx.body.refreshToken);
|
|
71
|
+
if (!found || new Date(found.session.expiresAt) <= /* @__PURE__ */ new Date()) throw refreshTokenNotFound();
|
|
72
|
+
const token = generateRandomString(32);
|
|
73
|
+
if (!await internalAdapter.updateSession(ctx.body.refreshToken, {
|
|
74
|
+
token,
|
|
75
|
+
expiresAt: new Date(Date.now() + sessionConfig.expiresIn * 1e3)
|
|
76
|
+
})) throw refreshTokenNotFound();
|
|
77
|
+
return ctx.json(await sessionOf(ctx, token));
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
//#endregion
|
|
83
|
+
export { goTrueSessionsPlugin };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { GoTrueError } from "./errors.js";
|
|
2
|
+
import { callBetterAuth, signedInToken } from "./forward.js";
|
|
3
|
+
import { address, readBody } from "./request-body.js";
|
|
4
|
+
import { takeAuthCode } from "./auth-codes.js";
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import * as z$1 from "zod";
|
|
7
|
+
//#region src/gotrue/token-route.ts
|
|
8
|
+
const passwordGrant = z$1.object({
|
|
9
|
+
email: address,
|
|
10
|
+
password: z$1.string().min(1)
|
|
11
|
+
});
|
|
12
|
+
const refreshGrant = z$1.object({ refresh_token: z$1.string().min(1) });
|
|
13
|
+
const pkceGrant = z$1.object({
|
|
14
|
+
auth_code: z$1.string().min(1),
|
|
15
|
+
code_verifier: z$1.string().min(1)
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* `POST /token?grant_type=…`: a session for a password (`password`, through Better Auth's own sign-in, with its rate
|
|
19
|
+
* limit and sign-in policy), for the refresh token of one (`refresh_token`), or for the code a provider's sign-in
|
|
20
|
+
* handed back with its PKCE verifier (`pkce`).
|
|
21
|
+
*/
|
|
22
|
+
const tokenRoute = ({ auth, context, sessionOf }) => new Hono().post("/token", async (c) => {
|
|
23
|
+
const grant = c.req.query("grant_type");
|
|
24
|
+
if (grant === "password") {
|
|
25
|
+
const { json } = await callBetterAuth(auth, c.req.raw, "/sign-in/email", await readBody(c.req.raw, passwordGrant));
|
|
26
|
+
return c.json(await sessionOf(signedInToken(json)));
|
|
27
|
+
}
|
|
28
|
+
if (grant === "refresh_token") {
|
|
29
|
+
const { refresh_token } = await readBody(c.req.raw, refreshGrant);
|
|
30
|
+
return c.json(await auth.api.goTrueRefresh({ body: { refreshToken: refresh_token } }));
|
|
31
|
+
}
|
|
32
|
+
if (grant === "pkce") {
|
|
33
|
+
const { auth_code, code_verifier } = await readBody(c.req.raw, pkceGrant);
|
|
34
|
+
const ctx = await context();
|
|
35
|
+
const userId = await takeAuthCode(ctx, auth_code, code_verifier);
|
|
36
|
+
const session = await ctx.internalAdapter.createSession(userId);
|
|
37
|
+
return c.json(await sessionOf(session.token));
|
|
38
|
+
}
|
|
39
|
+
throw new GoTrueError(400, "unsupported_grant_type", "Supported grant types are password, refresh_token and pkce");
|
|
40
|
+
});
|
|
41
|
+
//#endregion
|
|
42
|
+
export { tokenRoute };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//#region src/gotrue/user-json.ts
|
|
2
|
+
const iso = (value) => new Date(value).toISOString();
|
|
3
|
+
const providerName = (providerId) => providerId === "credential" ? "email" : providerId;
|
|
4
|
+
const byAge = (accounts) => [...accounts].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
|
5
|
+
/** `app_metadata`: the provider the user first signed in with, and every one they can. */
|
|
6
|
+
const appMetadata = (accounts) => {
|
|
7
|
+
const providers = [...new Set(byAge(accounts).map((account) => providerName(account.providerId)))];
|
|
8
|
+
return providers.length === 0 ? {} : {
|
|
9
|
+
provider: providers[0],
|
|
10
|
+
providers
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
/** `user_metadata`: the user metadata column's object, read as the store returns JSON; empty without one. */
|
|
14
|
+
const userMetadata = (metadata) => {
|
|
15
|
+
const value = typeof metadata === "string" ? JSON.parse(metadata) : metadata;
|
|
16
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
17
|
+
};
|
|
18
|
+
/** The GoTrue user object of `user`. `confirmed_at` is the address's confirmation, as phones are not offered. */
|
|
19
|
+
const goTrueUser = (user, accounts) => {
|
|
20
|
+
const confirmedAt = user.emailConfirmedAt ? iso(user.emailConfirmedAt) : void 0;
|
|
21
|
+
return {
|
|
22
|
+
id: user.id,
|
|
23
|
+
aud: "authenticated",
|
|
24
|
+
role: "authenticated",
|
|
25
|
+
email: user.email,
|
|
26
|
+
...confirmedAt && {
|
|
27
|
+
email_confirmed_at: confirmedAt,
|
|
28
|
+
confirmed_at: confirmedAt
|
|
29
|
+
},
|
|
30
|
+
app_metadata: appMetadata(accounts),
|
|
31
|
+
user_metadata: userMetadata(user.metadata),
|
|
32
|
+
identities: byAge(accounts).map((account) => ({
|
|
33
|
+
identity_id: account.id,
|
|
34
|
+
id: account.accountId,
|
|
35
|
+
user_id: account.userId,
|
|
36
|
+
provider: providerName(account.providerId),
|
|
37
|
+
created_at: iso(account.createdAt),
|
|
38
|
+
updated_at: iso(account.updatedAt)
|
|
39
|
+
})),
|
|
40
|
+
created_at: iso(user.createdAt),
|
|
41
|
+
updated_at: iso(user.updatedAt),
|
|
42
|
+
is_anonymous: false
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
//#endregion
|
|
46
|
+
export { appMetadata, goTrueUser, userMetadata };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { effectiveSignInPolicy } from "../better-auth/sign-in-policy.js";
|
|
2
|
+
import { readSignInPolicy } from "../better-auth/policy-store.js";
|
|
3
|
+
import { goTrueUser, userMetadata } from "./user-json.js";
|
|
4
|
+
import { checkNewAccountPassword } from "../better-auth/new-account-password.js";
|
|
5
|
+
import { GoTrueError } from "./errors.js";
|
|
6
|
+
import { readBody } from "./request-body.js";
|
|
7
|
+
import { accessClaimsOf } from "./bearer.js";
|
|
8
|
+
import { goTrueTokens } from "./access-tokens.js";
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
import * as z$1 from "zod";
|
|
11
|
+
//#region src/gotrue/user-routes.ts
|
|
12
|
+
const updateBody = z$1.object({
|
|
13
|
+
email: z$1.string().optional(),
|
|
14
|
+
password: z$1.string().optional(),
|
|
15
|
+
data: z$1.record(z$1.string(), z$1.unknown()).optional()
|
|
16
|
+
});
|
|
17
|
+
const logoutScopes = [
|
|
18
|
+
"global",
|
|
19
|
+
"local",
|
|
20
|
+
"others"
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* The signed-in user's own endpoints, with their access token: `GET /user`, `PUT /user` for a new password or metadata,
|
|
24
|
+
* and `POST /logout`. A token whose session has ended, by signing out or a ban, is GoTrue's `session_not_found`.
|
|
25
|
+
*/
|
|
26
|
+
const userRoutes = ({ auth, context }) => {
|
|
27
|
+
const claimsOf = accessClaimsOf(goTrueTokens({ auth }));
|
|
28
|
+
const signedIn = async (request) => {
|
|
29
|
+
const claims = await claimsOf(request);
|
|
30
|
+
const ctx = await context();
|
|
31
|
+
const user = await ctx.adapter.findOne({
|
|
32
|
+
model: "session",
|
|
33
|
+
where: [{
|
|
34
|
+
field: "id",
|
|
35
|
+
value: claims.session_id
|
|
36
|
+
}]
|
|
37
|
+
}) ? await ctx.internalAdapter.findUserById(claims.sub) : null;
|
|
38
|
+
if (!user) throw new GoTrueError(403, "session_not_found", "Session from session_id claim in JWT does not exist");
|
|
39
|
+
return {
|
|
40
|
+
claims,
|
|
41
|
+
user,
|
|
42
|
+
ctx
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
const userJson = async ({ user, ctx }) => goTrueUser(user, await ctx.internalAdapter.findAccounts(user.id));
|
|
46
|
+
const signOut = async (claims, scope) => {
|
|
47
|
+
const { adapter } = await context();
|
|
48
|
+
const others = {
|
|
49
|
+
field: "id",
|
|
50
|
+
value: claims.session_id,
|
|
51
|
+
operator: "ne"
|
|
52
|
+
};
|
|
53
|
+
const where = scope === "local" ? [{
|
|
54
|
+
field: "id",
|
|
55
|
+
value: claims.session_id
|
|
56
|
+
}] : [{
|
|
57
|
+
field: "userId",
|
|
58
|
+
value: claims.sub
|
|
59
|
+
}, ...scope === "others" ? [others] : []];
|
|
60
|
+
await adapter.deleteMany({
|
|
61
|
+
model: "session",
|
|
62
|
+
where
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
return new Hono().get("/user", async (c) => c.json(await userJson(await signedIn(c.req.raw)))).put("/user", async (c) => {
|
|
66
|
+
const { email, password, data } = await readBody(c.req.raw, updateBody);
|
|
67
|
+
const found = await signedIn(c.req.raw);
|
|
68
|
+
const { user, ctx } = found;
|
|
69
|
+
if (email !== void 0 && email.trim().toLowerCase() !== user.email) throw new GoTrueError(422, "email_change_not_allowed", "Changing the email address is not offered");
|
|
70
|
+
if (data !== void 0 && !auth.userMetadata) throw new GoTrueError(400, "validation_failed", "This app keeps no user metadata: leave data out");
|
|
71
|
+
if (password !== void 0) {
|
|
72
|
+
checkNewAccountPassword(effectiveSignInPolicy((await readSignInPolicy(ctx.adapter)).policy, { mail: auth.mail }), password);
|
|
73
|
+
const hash = await ctx.password.hash(password);
|
|
74
|
+
if (await ctx.internalAdapter.findCredentialAccount(user.id)) await ctx.internalAdapter.updatePassword(user.id, hash);
|
|
75
|
+
else await ctx.internalAdapter.linkAccount({
|
|
76
|
+
userId: user.id,
|
|
77
|
+
providerId: "credential",
|
|
78
|
+
accountId: user.id,
|
|
79
|
+
password: hash
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const merged = data && Object.fromEntries(Object.entries({
|
|
83
|
+
...userMetadata(user.metadata),
|
|
84
|
+
...data
|
|
85
|
+
}).filter(([, value]) => value !== null));
|
|
86
|
+
const updated = merged ? await ctx.internalAdapter.updateUser(user.id, { metadata: merged }) : user;
|
|
87
|
+
return c.json(await userJson({
|
|
88
|
+
...found,
|
|
89
|
+
user: updated
|
|
90
|
+
}));
|
|
91
|
+
}).post("/logout", async (c) => {
|
|
92
|
+
const scope = c.req.query("scope") ?? "global";
|
|
93
|
+
if (!logoutScopes.includes(scope)) throw new GoTrueError(400, "validation_failed", "scope must be global, local or others");
|
|
94
|
+
await signOut(await claimsOf(c.req.raw), scope);
|
|
95
|
+
return c.body(null, 204);
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
//#endregion
|
|
99
|
+
export { userRoutes };
|
package/dist/index.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { RecordKeyInput, ToolRecord, ToolRecords } from "./assistant/records.js"
|
|
|
32
32
|
import { FunctionRecords, ListParams, RecordPage } from "./functions/records.js";
|
|
33
33
|
import { ApiFunction, CorsOptions, FunctionApp, FunctionCaller, FunctionContext, FunctionHandler, FunctionOptions, FunctionSource, ProtectedFunctionOptions, PublicFunctionContext, PublicFunctionHandler, PublicFunctionOptions, defineFunction } from "./functions/define-function.js";
|
|
34
34
|
import { CorsOrigins } from "./api-cors.js";
|
|
35
|
+
import { ApiKeys, ApiKeysOptions, apiKeySettings, apiKeyVariables } from "./api-keys/api-key-settings.js";
|
|
35
36
|
import { ConversationRecord, ConversationStore } from "./assistant/conversation-store.js";
|
|
36
37
|
import { Conversation, ConversationsOptions, conversationKey, createConversations } from "./assistant/conversations.js";
|
|
37
38
|
import { ApprovalAnswer, ApprovalRequest, requestApproval } from "./assistant/approval.js";
|
|
@@ -46,7 +47,6 @@ import { checkViewQueries } from "./check-view-queries.js";
|
|
|
46
47
|
import { ProjectConfig, defineConfig, mergeConfig } from "./project-config.js";
|
|
47
48
|
import { consoleAuditQueue } from "./audit/console-queue.js";
|
|
48
49
|
import { JwtOptions, JwtVerifyOptions, jwtAuthenticator, jwtVerifier } from "./auth/jwt.js";
|
|
49
|
-
import { ApiKeys, ApiKeysOptions, apiKeySettings, apiKeyVariables } from "./api-keys/api-key-settings.js";
|
|
50
50
|
import { Caller, CallerOptions, apiKeyCaller } from "./api-keys/resolve-caller.js";
|
|
51
51
|
import { HttpProblem, MissingCredentials, badRequest, forbidden, notFound, unauthorized } from "./problem.js";
|
|
52
52
|
import { TrustedApp, withTrustedApps } from "./better-auth/app-tokens.js";
|
package/dist/index.js
CHANGED
|
@@ -17,11 +17,16 @@ import { withCacheBreakpoints } from "./assistant/cache-breakpoints.js";
|
|
|
17
17
|
import { runTurn } from "./assistant/turn.js";
|
|
18
18
|
import { consoleAuditQueue } from "./audit/console-queue.js";
|
|
19
19
|
import { webauthnPath } from "./better-auth/origins.js";
|
|
20
|
-
import {
|
|
20
|
+
import { apiKeySettings, apiKeyVariables } from "./api-keys/api-key-settings.js";
|
|
21
|
+
import { apiKeyCaller } from "./api-keys/resolve-caller.js";
|
|
22
|
+
import { jwtAuthenticator, jwtVerifier } from "./auth/jwt.js";
|
|
23
|
+
import { defaultSignInPolicy, signInPolicyMethods } from "./better-auth/sign-in-policy.js";
|
|
21
24
|
import { parseRoles } from "./better-auth/parse-roles.js";
|
|
25
|
+
import { readOperatorSettings } from "./better-auth/operator-provider.js";
|
|
22
26
|
import { readPlatformSignIn } from "./better-auth/platform-sign-in.js";
|
|
23
|
-
import { defaultSignInPolicy, signInPolicyMethods } from "./better-auth/sign-in-policy.js";
|
|
24
27
|
import { createAuth, normalizeRoles } from "./better-auth/create-auth.js";
|
|
28
|
+
import { withTrustedApps } from "./better-auth/app-tokens.js";
|
|
29
|
+
import { betterAuthAuthenticator, betterAuthTokens } from "./better-auth/authenticator.js";
|
|
25
30
|
import { addAppRoleToOwners, addMember, createOrganization, listOrganizations, removeMember, setMemberRoles } from "./better-auth/organizations/host.js";
|
|
26
31
|
import { createUser, deleteUser, hasUsers, listUsers, roleChoices, setUserBanned, setUserRole } from "./better-auth/users.js";
|
|
27
32
|
import { changesMigration, checkChanges, migrationCommand } from "./changes/capture-schema.js";
|
|
@@ -49,11 +54,6 @@ import { handleDroppedConnections } from "./dropped-connections.js";
|
|
|
49
54
|
import { configExports } from "./resource-source.js";
|
|
50
55
|
import { checkViewQueries } from "./check-view-queries.js";
|
|
51
56
|
import { defineConfig, mergeConfig } from "./project-config.js";
|
|
52
|
-
import { jwtAuthenticator, jwtVerifier } from "./auth/jwt.js";
|
|
53
|
-
import { apiKeySettings, apiKeyVariables } from "./api-keys/api-key-settings.js";
|
|
54
|
-
import { apiKeyCaller } from "./api-keys/resolve-caller.js";
|
|
55
|
-
import { withTrustedApps } from "./better-auth/app-tokens.js";
|
|
56
|
-
import { betterAuthAuthenticator, betterAuthTokens } from "./better-auth/authenticator.js";
|
|
57
57
|
import { issueToken, maxTokenTtlSeconds } from "./better-auth/tokens.js";
|
|
58
58
|
import { authSchemaMigration, checkAuthSchema } from "./better-auth/auth-schema.js";
|
|
59
59
|
import { checkComputed, computedMigration } from "./computed/computed-schema.js";
|
package/dist/live/notices-sql.js
CHANGED
|
@@ -22,7 +22,8 @@ const noticeOf = (row) => ({
|
|
|
22
22
|
resource: row.resource,
|
|
23
23
|
key: row.record_key,
|
|
24
24
|
operation: row.operation,
|
|
25
|
-
tenant: row.tenant_id
|
|
25
|
+
tenant: row.tenant_id,
|
|
26
|
+
at: row.at
|
|
26
27
|
});
|
|
27
28
|
const tableOf = (schema) => sql.raw(`${quoteIdent(schema)}."notices"`);
|
|
28
29
|
/**
|
|
@@ -47,7 +48,8 @@ const writeNotices = async (db, schema, changes) => {
|
|
|
47
48
|
const lastNotice = async (db, schema) => (await sql`select coalesce(max("id"), 0)::text as id from ${tableOf(schema)}`.execute(db)).rows[0].id;
|
|
48
49
|
/** At most `limit` notices after `after`, in order. */
|
|
49
50
|
const noticesAfter = async (db, schema, after, limit) => {
|
|
50
|
-
const { rows } = await sql`select "id"::text as id, "resource", "record_key", "tenant_id", "operation"
|
|
51
|
+
const { rows } = await sql`select "id"::text as id, "resource", "record_key", "tenant_id", "operation",
|
|
52
|
+
to_char("at" at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') as at from ${tableOf(schema)}
|
|
51
53
|
where "id" > ${after}::bigint order by "id" limit ${limit}`.execute(db);
|
|
52
54
|
return rows.map(noticeOf);
|
|
53
55
|
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readTransaction } from "../transactions.js";
|
|
2
|
+
import { quoteIdent, tableSql } from "../changes/capture-sql.js";
|
|
3
|
+
import { callerSettings } from "../rest/caller-settings.js";
|
|
4
|
+
import { filterSql } from "./change-filter.js";
|
|
5
|
+
import { decodeKey, encodeKey } from "@protobase/schema/internal";
|
|
6
|
+
import { sql } from "kysely";
|
|
7
|
+
//#region src/realtime/change-checker.ts
|
|
8
|
+
const keyColumns = (entry) => entry.model.primaryKey.map((name) => entry.model.fields[name].column);
|
|
9
|
+
const keyRows = (entry, keys) => {
|
|
10
|
+
const columns = keyColumns(entry);
|
|
11
|
+
return JSON.stringify(keys.map((key) => {
|
|
12
|
+
const decoded = decodeKey(key, entry.keyTypes);
|
|
13
|
+
const parts = Array.isArray(decoded) ? decoded : [decoded];
|
|
14
|
+
return Object.fromEntries(columns.map((column, index) => [column, parts[index]]));
|
|
15
|
+
}));
|
|
16
|
+
};
|
|
17
|
+
const listed = (alias, columns) => sql.raw(columns.map((column) => `${alias}.${quoteIdent(column)}`).join(", "));
|
|
18
|
+
const keyTexts = (alias, entry) => sql.raw(keyColumns(entry).map((column, index) => `${alias}.${quoteIdent(column)}::text as "k${index}"`).join(", "));
|
|
19
|
+
const filterColumns = (alias, filters) => filters.length === 0 ? sql`` : sql`, ${sql.join(filters.map((conditions, index) => sql`${filterSql(alias, conditions)} as ${sql.ref(`f${index}`)}`))}`;
|
|
20
|
+
const seen = (entry, filters, rows) => new Map(rows.map((row) => [encodeKey(keyColumns(entry).map((_, index) => row[`k${index}`])), {
|
|
21
|
+
record: row.record ?? {},
|
|
22
|
+
oldRecord: row.old_record,
|
|
23
|
+
matches: filters.map((_, index) => row[`f${index}`] === true)
|
|
24
|
+
}]));
|
|
25
|
+
/**
|
|
26
|
+
* Checks which changes callers may see, for every group in one read-only transaction: per group it sets the role and
|
|
27
|
+
* claims as `/rest/v1` does, then reads the current rows by key, so the table's RLS policies decide, and evaluates
|
|
28
|
+
* each filter in Postgres, as the column's type compares. The transaction holds one pooled connection only while it
|
|
29
|
+
* runs. A group whose check fails is checked again in a transaction of its own, so it costs only its own callers the
|
|
30
|
+
* batch.
|
|
31
|
+
*/
|
|
32
|
+
const changeChecker = ({ db, statementTimeoutMs, report }) => {
|
|
33
|
+
const columnCache = /* @__PURE__ */ new Map();
|
|
34
|
+
const columnsOf = async (trx, entry, role) => {
|
|
35
|
+
const cached = columnCache.get(`${entry.name}\0${role}`);
|
|
36
|
+
if (cached) return cached;
|
|
37
|
+
const { rows } = await sql`select a.attname as name, t.typname as type from pg_attribute a join pg_type t on t.oid = a.atttypid
|
|
38
|
+
where a.attrelid = to_regclass(${tableSql(entry.model)}) and a.attnum > 0 and not a.attisdropped and has_column_privilege(a.attrelid, a.attnum, 'SELECT')
|
|
39
|
+
order by a.attnum`.execute(trx);
|
|
40
|
+
columnCache.set(`${entry.name}\0${role}`, rows);
|
|
41
|
+
return rows;
|
|
42
|
+
};
|
|
43
|
+
const view = async (trx, role, check) => {
|
|
44
|
+
const columns = await columnsOf(trx, check.entry, role);
|
|
45
|
+
const names = columns.map((column) => column.name);
|
|
46
|
+
if (!keyColumns(check.entry).every((column) => names.includes(column))) return {
|
|
47
|
+
columns,
|
|
48
|
+
rows: /* @__PURE__ */ new Map(),
|
|
49
|
+
deleted: /* @__PURE__ */ new Map()
|
|
50
|
+
};
|
|
51
|
+
const table = sql.raw(tableSql(check.entry.model));
|
|
52
|
+
const key = listed("t", keyColumns(check.entry));
|
|
53
|
+
const rows = check.keys.length === 0 ? [] : (await sql`select ${keyTexts("t", check.entry)}, (select to_jsonb(v) from (select ${listed("t", names)}) v) as record,
|
|
54
|
+
(select to_jsonb(v) from (select ${key}) v) as old_record${filterColumns("t", check.filters)}
|
|
55
|
+
from ${table} t where (${key}) in (select ${listed("k", keyColumns(check.entry))} from jsonb_populate_recordset(null::${table}, ${keyRows(check.entry, check.keys)}::jsonb) k)`.execute(trx)).rows;
|
|
56
|
+
const deleted = check.deleted.length === 0 ? [] : (await sql`select ${keyTexts("k", check.entry)}, (select to_jsonb(v) from (select ${listed("k", keyColumns(check.entry))}) v) as old_record${filterColumns("k", check.filters)}
|
|
57
|
+
from jsonb_populate_recordset(null::${table}, ${keyRows(check.entry, check.deleted)}::jsonb) k`.execute(trx)).rows;
|
|
58
|
+
return {
|
|
59
|
+
columns,
|
|
60
|
+
rows: seen(check.entry, check.filters, rows),
|
|
61
|
+
deleted: seen(check.entry, check.filters, deleted)
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
const checkGroups = (groups) => readTransaction(db, statementTimeoutMs, async (trx) => {
|
|
65
|
+
const views = [];
|
|
66
|
+
for (const group of groups) {
|
|
67
|
+
const settings = {
|
|
68
|
+
role: "none",
|
|
69
|
+
"request.jwt.claim.sub": "",
|
|
70
|
+
...callerSettings(group.caller)
|
|
71
|
+
};
|
|
72
|
+
await sql`select ${sql.join(Object.entries(settings).map(([name, value]) => sql`set_config(${name}, ${value}, true)`))}`.execute(trx);
|
|
73
|
+
const role = group.caller.role === "app" ? "app" : group.caller.role;
|
|
74
|
+
const resources = [];
|
|
75
|
+
for (const check of group.resources) resources.push(await view(trx, role, check));
|
|
76
|
+
views.push(resources);
|
|
77
|
+
}
|
|
78
|
+
return views;
|
|
79
|
+
});
|
|
80
|
+
const nothing = (group) => group.resources.map(() => ({
|
|
81
|
+
columns: [],
|
|
82
|
+
rows: /* @__PURE__ */ new Map(),
|
|
83
|
+
deleted: /* @__PURE__ */ new Map()
|
|
84
|
+
}));
|
|
85
|
+
return async (groups) => {
|
|
86
|
+
if (groups.length === 0) return [];
|
|
87
|
+
return checkGroups(groups).catch(async (error) => {
|
|
88
|
+
if (groups.length === 1) {
|
|
89
|
+
report(error);
|
|
90
|
+
return [nothing(groups[0])];
|
|
91
|
+
}
|
|
92
|
+
const views = [];
|
|
93
|
+
for (const group of groups) views.push(await checkGroups([group]).then((checked) => checked[0], (failed) => {
|
|
94
|
+
report(failed);
|
|
95
|
+
return nothing(group);
|
|
96
|
+
}));
|
|
97
|
+
return views;
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
//#endregion
|
|
102
|
+
export { changeChecker };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { quoteIdent } from "../changes/capture-sql.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/realtime/change-filter.ts
|
|
4
|
+
/** The operators of a `postgres_changes` filter, as Supabase Realtime takes them, and the SQL each is. */
|
|
5
|
+
const operators = {
|
|
6
|
+
eq: "=",
|
|
7
|
+
neq: "<>",
|
|
8
|
+
lt: "<",
|
|
9
|
+
lte: "<=",
|
|
10
|
+
gt: ">",
|
|
11
|
+
gte: ">=",
|
|
12
|
+
in: "= any",
|
|
13
|
+
like: "like",
|
|
14
|
+
ilike: "ilike",
|
|
15
|
+
match: "~",
|
|
16
|
+
imatch: "~*",
|
|
17
|
+
is: "is",
|
|
18
|
+
isdistinct: "is distinct from"
|
|
19
|
+
};
|
|
20
|
+
const isOperator = (name) => Object.hasOwn(operators, name);
|
|
21
|
+
const isKeyword = /* @__PURE__ */ new Set([
|
|
22
|
+
"null",
|
|
23
|
+
"true",
|
|
24
|
+
"false",
|
|
25
|
+
"unknown"
|
|
26
|
+
]);
|
|
27
|
+
const segments = (filter) => {
|
|
28
|
+
const parts = [];
|
|
29
|
+
let current = "";
|
|
30
|
+
let depth = 0;
|
|
31
|
+
let quoted = false;
|
|
32
|
+
for (let index = 0; index < filter.length; index++) {
|
|
33
|
+
const char = filter[index];
|
|
34
|
+
if (quoted && char === "\\") {
|
|
35
|
+
current += char + (filter[++index] ?? "");
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (char === "\"" && (quoted || [
|
|
39
|
+
".",
|
|
40
|
+
"(",
|
|
41
|
+
","
|
|
42
|
+
].includes(current.at(-1) ?? ","))) quoted = !quoted;
|
|
43
|
+
else if (!quoted && char === "(") depth++;
|
|
44
|
+
else if (!quoted && char === ")") depth = Math.max(0, depth - 1);
|
|
45
|
+
else if (!quoted && depth === 0 && char === ",") {
|
|
46
|
+
parts.push(current);
|
|
47
|
+
current = "";
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
current += char;
|
|
51
|
+
}
|
|
52
|
+
return [...parts, current];
|
|
53
|
+
};
|
|
54
|
+
const unquote = (value) => {
|
|
55
|
+
if (!value.startsWith("\"") || !value.endsWith("\"") || value.length < 2) return value;
|
|
56
|
+
return value.slice(1, -1).replace(/\\(.)/g, "$1");
|
|
57
|
+
};
|
|
58
|
+
const condition = (segment) => {
|
|
59
|
+
const trimmed = segment.trim();
|
|
60
|
+
if (trimmed === "") return "filter must not contain empty segments (check for extra commas)";
|
|
61
|
+
const equals = trimmed.indexOf("=");
|
|
62
|
+
if (equals < 1) return `invalid filter "${trimmed}": use column=operator.value`;
|
|
63
|
+
const column = trimmed.slice(0, equals);
|
|
64
|
+
let rest = trimmed.slice(equals + 1);
|
|
65
|
+
const negate = rest.startsWith("not.");
|
|
66
|
+
if (negate) rest = rest.slice(4);
|
|
67
|
+
const dot = rest.indexOf(".");
|
|
68
|
+
if (dot < 0) return `invalid filter "${trimmed}": use column=operator.value`;
|
|
69
|
+
const operator = rest.slice(0, dot);
|
|
70
|
+
const value = rest.slice(dot + 1);
|
|
71
|
+
if (!isOperator(operator)) return `invalid filter "${trimmed}": unknown operator "${operator}"`;
|
|
72
|
+
if (operator === "in") {
|
|
73
|
+
if (!value.startsWith("(") || !value.endsWith(")")) return "`in` filter value must be wrapped by parentheses";
|
|
74
|
+
return {
|
|
75
|
+
column,
|
|
76
|
+
operator,
|
|
77
|
+
value: `{${value.slice(1, -1)}}`,
|
|
78
|
+
negate
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (operator === "is" && !isKeyword.has(value)) return "invalid value for is filter: must be null, true, false, or unknown";
|
|
82
|
+
return {
|
|
83
|
+
column,
|
|
84
|
+
operator,
|
|
85
|
+
value: unquote(value),
|
|
86
|
+
negate
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
/** The conditions of a filter, all of which a row must meet; an error message for one Supabase refuses too. */
|
|
90
|
+
const parseFilter = (filter) => {
|
|
91
|
+
if (filter === void 0 || filter.trim() === "") return [];
|
|
92
|
+
const parsed = segments(filter.trim()).map(condition);
|
|
93
|
+
return parsed.find((item) => typeof item === "string") ?? parsed;
|
|
94
|
+
};
|
|
95
|
+
const conditionSql = (alias, { column, operator, value, negate }) => {
|
|
96
|
+
const target = sql.raw(`${alias}.${quoteIdent(column)}`);
|
|
97
|
+
const test = operator === "is" ? sql`${target} is ${sql.raw(value)}` : operator === "in" ? sql`${target} = any(${value})` : sql`${target} ${sql.raw(operators[operator])} ${value}`;
|
|
98
|
+
return negate ? sql`not (${test})` : test;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Whether the row `alias` meets every condition, as a boolean that is never null: a condition Postgres answers with
|
|
102
|
+
* null, such as one on a null column, is not met, as on Supabase.
|
|
103
|
+
*/
|
|
104
|
+
const filterSql = (alias, conditions) => conditions.length === 0 ? sql`true` : sql`coalesce(${sql.join(conditions.map((item) => conditionSql(alias, item)), sql` and `)}, false)`;
|
|
105
|
+
//#endregion
|
|
106
|
+
export { filterSql, parseFilter };
|