hecks 3.4.2 → 3.5.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.
- checksums.yaml +4 -4
- data/lib/hecks/deploy/bluebook/deploy.bluebook +1510 -3
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +57 -0
- data/lib/hecks/deploy/oidc.json +225 -0
- data/lib/hecks/hecks/adapters/bluebook_report.rb +91 -0
- data/lib/hecks/hecks/adapters/deploy_plans.rb +49 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +295 -14
- data/lib/hecks/hecks/adapters/in_process_operations.rb +6 -2
- data/lib/hecks/hecks/adapters/site_toolchain.rb +3 -3
- data/lib/hecks/hecks/custodian.bluebook +12 -2
- data/lib/hecks/hecks/hecks.hecksagon +57 -0
- data/lib/hecks/hecks/hecks.world +5 -1
- data/lib/hecks/projections/deploy/box/hosting.rb +13 -4
- data/lib/hecks/projections/deploy/box/roll_recipe.rb +62 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +10 -3
- data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +2 -2
- data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +16 -4
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +6 -6
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +1 -1
- data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +6 -3
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +8 -3
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -2
- data/lib/hecks/projections/deploy/box.rb +8 -14
- data/lib/hecks/projections/site/admin.rb +140 -0
- data/lib/hecks/projections/site/admin_cms/templates.rb +388 -0
- data/lib/hecks/projections/site/admin_cms.rb +72 -0
- data/lib/hecks/projections/site/payload/fields.rb +234 -0
- data/lib/hecks/projections/site/payload/lifecycle.rb +191 -0
- data/lib/hecks/projections/site/payload/model.rb +128 -0
- data/lib/hecks/projections/site/payload/specs.rb +179 -0
- data/lib/hecks/projections/site/payload_driver.rb +93 -0
- data/lib/hecks/projections/site/root_rows.rb +59 -0
- data/lib/hecks/projections/site/site_admin.rb +292 -0
- data/lib/hecks/projections/site/site_host/templates.rb +165 -0
- data/lib/hecks/projections/site/site_host.rb +58 -0
- data/lib/hecks/projections/site/site_root.rb +179 -0
- data/lib/hecks/site/bluebook/site.bluebook +7 -1
- data/lib/hecks/tools/site_routes.rb +78 -8
- data/lib/hecks/version.rb +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- metadata +17 -1
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Projections
|
|
5
|
+
module Site
|
|
6
|
+
module AdminCms
|
|
7
|
+
# The TypeScript text of the four files, with `__NAME__` placeholders that `AdminCms.fill`
|
|
8
|
+
# replaces from the admin row. Each reads as the file a project would write by hand.
|
|
9
|
+
module Templates
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def membership
|
|
13
|
+
<<~'TS'
|
|
14
|
+
__BANNER__
|
|
15
|
+
|
|
16
|
+
// Is this editor still admitted? The domain's membership list decides, not the content system.
|
|
17
|
+
// Signing in (endpoints/sso.ts) proved the person was admitted then; this asks again, so that
|
|
18
|
+
// disabling or removing someone locks them out on their next request, not when their session runs out.
|
|
19
|
+
//
|
|
20
|
+
// The question is the host's own account route, which answers only for a person who holds a role
|
|
21
|
+
// and is not disabled, asked with a short account token signed by the secret the host checks (the
|
|
22
|
+
// content system's AUTH_SECRET is the host's SESSION_SECRET). The answer is remembered for a minute
|
|
23
|
+
// per email so every admin request does not cost a round trip.
|
|
24
|
+
import { accountToken, resolveAccountCookieName } from "@hecks/client";
|
|
25
|
+
|
|
26
|
+
const DEFAULT_URL = __HOST_DEFAULT__;
|
|
27
|
+
const DEFAULT_COOKIE = __COOKIE__;
|
|
28
|
+
const TOKEN_TTL_SECONDS = 60;
|
|
29
|
+
export const REMEMBER_MS = 60_000;
|
|
30
|
+
|
|
31
|
+
// The shared secret. With none set outside production the host's development default is used, as
|
|
32
|
+
// endpoints/sso.ts does; in production an unset secret refuses every question.
|
|
33
|
+
const AUTH_SECRET =
|
|
34
|
+
process.env.AUTH_SECRET || (process.env.NODE_ENV === "production" ? "" : "dev_only_change_me_in_production");
|
|
35
|
+
|
|
36
|
+
export interface MembershipCheck {
|
|
37
|
+
/** True while `email` is admitted; false when membership says not, or cannot be asked. */
|
|
38
|
+
isAdmitted(email: string): Promise<boolean>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MembershipOptions {
|
|
42
|
+
url?: string;
|
|
43
|
+
/** The host route asked: the account route (any admitted person) by default, the members list for admin roles only. */
|
|
44
|
+
path?: string;
|
|
45
|
+
/** Decides from the host's answer; the default is any 2xx. */
|
|
46
|
+
accept?: (res: Response, email: string) => Promise<boolean>;
|
|
47
|
+
secret?: string;
|
|
48
|
+
cookieName?: string;
|
|
49
|
+
fetch?: typeof fetch;
|
|
50
|
+
now?: () => number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A membership check with its own one-minute memory. */
|
|
54
|
+
export function membershipCheck(opts: MembershipOptions = {}): MembershipCheck {
|
|
55
|
+
const remembered = new Map<string, { admitted: boolean; until: number }>();
|
|
56
|
+
const now = opts.now ?? Date.now;
|
|
57
|
+
const doFetch = opts.fetch ?? fetch;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
async isAdmitted(email: string): Promise<boolean> {
|
|
61
|
+
const key = email.trim().toLowerCase();
|
|
62
|
+
const hit = remembered.get(key);
|
|
63
|
+
if (hit && hit.until > now()) return hit.admitted;
|
|
64
|
+
|
|
65
|
+
const secret = opts.secret ?? AUTH_SECRET;
|
|
66
|
+
const url = (opts.url ?? (process.env[__HOST_ENV__] || DEFAULT_URL)).replace(/\/+$/, "");
|
|
67
|
+
const cookieName = resolveAccountCookieName(opts.cookieName ?? (process.env.HECKS_SESSION_COOKIE || DEFAULT_COOKIE));
|
|
68
|
+
let admitted = false;
|
|
69
|
+
if (secret) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await doFetch(`${url}${opts.path ?? __ACCOUNT_PATH__}`, {
|
|
72
|
+
headers: { Cookie: `${cookieName}=${accountToken(secret, key, TOKEN_TTL_SECONDS)}` },
|
|
73
|
+
signal: AbortSignal.timeout(5_000),
|
|
74
|
+
});
|
|
75
|
+
admitted = opts.accept ? res.ok && (await opts.accept(res, key)) : res.ok;
|
|
76
|
+
} catch {
|
|
77
|
+
// Membership cannot be asked: refuse, as the content system already refuses to
|
|
78
|
+
// show domain-held fields it cannot read.
|
|
79
|
+
admitted = false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
remembered.set(key, { admitted, until: now() + REMEMBER_MS });
|
|
83
|
+
return admitted;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const membership = membershipCheck();
|
|
89
|
+
|
|
90
|
+
/** The roles the host's own admin gate passes. */
|
|
91
|
+
export const ADMIN_ROLES = __ROLES__;
|
|
92
|
+
|
|
93
|
+
/** True when `rows` (the host's members list) hold `email` with an admin role and not disabled. */
|
|
94
|
+
export function holdsAdminRole(rows: unknown, email: string): boolean {
|
|
95
|
+
return (
|
|
96
|
+
Array.isArray(rows) &&
|
|
97
|
+
rows.some((row: any) => typeof row?.email === "string" && row.email.trim().toLowerCase() === email && ADMIN_ROLES.includes(row.role) && row.disabled !== true)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Admin roles only. The host's members list answers any signed-in member, so it is the list's own
|
|
103
|
+
* row for the caller that decides, as the site's admin gate does.
|
|
104
|
+
*/
|
|
105
|
+
export const adminCheck = (opts: MembershipOptions = {}): MembershipCheck =>
|
|
106
|
+
membershipCheck({
|
|
107
|
+
...opts,
|
|
108
|
+
path: __MEMBERS_PATH__,
|
|
109
|
+
accept: async (res, email) => holdsAdminRole(await res.json().catch(() => null), email),
|
|
110
|
+
});
|
|
111
|
+
TS
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def session_strategy
|
|
115
|
+
<<~TS
|
|
116
|
+
__BANNER__
|
|
117
|
+
|
|
118
|
+
import { jwtVerify } from "jose";
|
|
119
|
+
import type { AuthStrategy, CollectionAfterLogoutHook } from "payload";
|
|
120
|
+
|
|
121
|
+
import { membership } from "./membership";
|
|
122
|
+
|
|
123
|
+
// Payload's own `local-jwt` strategy refuses any collection with `disableLocalStrategy` set
|
|
124
|
+
// ("JWT authentication requires an auth-enabled local collection") and is not even registered when
|
|
125
|
+
// every auth collection disables it. Users has no password login, so endpoints/sso.ts mints the
|
|
126
|
+
// session cookie and this verifies that same cookie itself: the token Payload's `jwtSign` produces
|
|
127
|
+
// ({ id, collection, sid }, HS256 over `payload.secret`, authVersion 1 in the protected header),
|
|
128
|
+
// then loads the user and requires its `sessions` entry to still exist so a logout or an expired
|
|
129
|
+
// session revokes the cookie.
|
|
130
|
+
const JWT_AUTH_VERSION = 1;
|
|
131
|
+
|
|
132
|
+
// Same cookie and CSRF rules as Payload's own extractJWT, cookie only: a request carrying an Origin
|
|
133
|
+
// must be on the csrf allowlist, and one without an Origin must be same-origin, same-site or a
|
|
134
|
+
// direct navigation. No Bearer or JWT header: nothing here mints tokens for API clients.
|
|
135
|
+
function sessionCookie(headers: Headers, cookiePrefix: string, csrf: string[]): string | null {
|
|
136
|
+
const name = `${cookiePrefix}-token`;
|
|
137
|
+
const raw = (headers.get("cookie") ?? "")
|
|
138
|
+
.split(";")
|
|
139
|
+
.map((part) => part.trim())
|
|
140
|
+
.find((part) => part.startsWith(`${name}=`));
|
|
141
|
+
if (!raw) return null;
|
|
142
|
+
const token = decodeURIComponent(raw.slice(name.length + 1));
|
|
143
|
+
|
|
144
|
+
const origin = headers.get("Origin");
|
|
145
|
+
if (origin) return csrf.length === 0 || csrf.includes(origin) ? token : null;
|
|
146
|
+
if (csrf.length === 0) return token;
|
|
147
|
+
return ["same-origin", "same-site", "none"].includes(headers.get("Sec-Fetch-Site") ?? "") ? token : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Payload's logout only deletes the session row when the local strategy is on, so without this the
|
|
151
|
+
// cookie it clears would still verify until it expires.
|
|
152
|
+
export const revokeSessionOnLogout: CollectionAfterLogoutHook = async ({ req }) => {
|
|
153
|
+
const { user, payload } = req;
|
|
154
|
+
const sid = (user as { _sid?: string } | null)?._sid;
|
|
155
|
+
if (!user || !sid) return;
|
|
156
|
+
|
|
157
|
+
const current = await payload.findByID({ collection: "users", id: user.id, depth: 0, overrideAccess: true, req });
|
|
158
|
+
const sessions = (current.sessions ?? []).filter((session) => session.id !== sid);
|
|
159
|
+
await payload.update({ collection: "users", id: user.id, data: { sessions }, overrideAccess: true, req });
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const sessionStrategy: AuthStrategy = {
|
|
163
|
+
name: "sso-session",
|
|
164
|
+
authenticate: async ({ headers, payload }) => {
|
|
165
|
+
const token = sessionCookie(headers, payload.config.cookiePrefix, payload.config.csrf);
|
|
166
|
+
if (!token) return { user: null };
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const { payload: claims, protectedHeader } = await jwtVerify(token, new TextEncoder().encode(payload.secret));
|
|
170
|
+
if (protectedHeader.authVersion !== JWT_AUTH_VERSION) return { user: null };
|
|
171
|
+
|
|
172
|
+
const { id, collection, sid } = claims as { id?: unknown; collection?: unknown; sid?: unknown };
|
|
173
|
+
if (collection !== "users" || typeof sid !== "string") return { user: null };
|
|
174
|
+
if (typeof id !== "number" && typeof id !== "string") return { user: null };
|
|
175
|
+
|
|
176
|
+
const user = await payload.findByID({ collection: "users", id, depth: 0, overrideAccess: true });
|
|
177
|
+
if (!user?.sessions?.some((session) => session.id === sid)) return { user: null };
|
|
178
|
+
// Payload's session alone is not enough: the domain's membership list must still admit this
|
|
179
|
+
// person (membership.ts), so disabling someone there locks them out here on their next request.
|
|
180
|
+
if (typeof user.email !== "string" || !(await membership.isAdmitted(user.email))) return { user: null };
|
|
181
|
+
|
|
182
|
+
return { user: { ...user, collection: "users", _strategy: "sso-session", _sid: sid } };
|
|
183
|
+
} catch {
|
|
184
|
+
return { user: null };
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
TS
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def sso
|
|
192
|
+
<<~TS
|
|
193
|
+
__BANNER__
|
|
194
|
+
|
|
195
|
+
import { verifyAccountToken } from "@hecks/client";
|
|
196
|
+
|
|
197
|
+
import { membership } from "../auth/membership";
|
|
198
|
+
import type { Endpoint } from "payload";
|
|
199
|
+
import { getFieldsToSign, jwtSign } from "payload";
|
|
200
|
+
import { addSessionToUser, generatePayloadCookie } from "payload/shared";
|
|
201
|
+
|
|
202
|
+
// The secret that signs and verifies the short-lived hand-off token only. It MUST be the same value
|
|
203
|
+
// as the domain host's SESSION_SECRET, or every token fails verification. Payload's own session
|
|
204
|
+
// cookie (minted below) is signed with Payload's own `secret`, unrelated. With none set outside
|
|
205
|
+
// production the host's development default is used; in production an unset secret refuses every token.
|
|
206
|
+
const AUTH_SECRET =
|
|
207
|
+
process.env.AUTH_SECRET || (process.env.NODE_ENV === "production" ? "" : "dev_only_change_me_in_production");
|
|
208
|
+
|
|
209
|
+
// The hand-off token is the host's account token; @hecks/client's verifyAccountToken checks its
|
|
210
|
+
// signature and expiry. What is kept here is the email's form: Payload lowercases and trims
|
|
211
|
+
// `users.email` on write (the users collection's own beforeChange), so the lookup, the lazy create
|
|
212
|
+
// and the retry below must use that same form: a mixed-case address in the token would otherwise
|
|
213
|
+
// miss the stored row, hit the unique index on create, miss again on the re-find, and fail every
|
|
214
|
+
// sign-in after the first. `normalizeEmail` does that, and refuses an address that is blank afterwards.
|
|
215
|
+
export function verifyHandoffToken(token: string): { email: string } | null {
|
|
216
|
+
if (!AUTH_SECRET) return null;
|
|
217
|
+
const claims = verifyAccountToken(token, AUTH_SECRET, { normalizeEmail: true });
|
|
218
|
+
return claims ? { email: claims.email } : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Real single sign-on, not a redirect trick: mints an actual Payload session cookie for the `users`
|
|
222
|
+
// doc matching the token's email, using the same getFieldsToSign, jwtSign and cookie pipeline
|
|
223
|
+
// Payload's own login endpoint uses, so the resulting session is indistinguishable from a real login.
|
|
224
|
+
// Nobody sees Payload's own login screen: the site's hand-off route sends a signed-in admin here
|
|
225
|
+
// with a fresh token, always through the site's own path to the content system, never its internal
|
|
226
|
+
// address, so this endpoint is reachable at __SSO_TARGET__ and the Set-Cookie below lands on the
|
|
227
|
+
// site's origin, not a cross-origin (third-party) one.
|
|
228
|
+
export const ssoEndpoint: Endpoint = {
|
|
229
|
+
path: __ENDPOINT__,
|
|
230
|
+
method: "get",
|
|
231
|
+
handler: async (req) => {
|
|
232
|
+
const token = req.searchParams.get("token");
|
|
233
|
+
const claims = token ? verifyHandoffToken(token) : null;
|
|
234
|
+
if (!claims) {
|
|
235
|
+
return new Response("Invalid or expired SSO token.", { status: 401 });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// A valid account token proves an account, not an admin: nobody gets a Payload user row or
|
|
239
|
+
// cookie unless the membership list currently admits them.
|
|
240
|
+
if (!(await membership.isAdmitted(claims.email))) {
|
|
241
|
+
return new Response("This account is not admitted to the CMS.", { status: 403 });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const matches = await req.payload.find({
|
|
245
|
+
collection: "users",
|
|
246
|
+
where: { email: { equals: claims.email } },
|
|
247
|
+
limit: 1,
|
|
248
|
+
});
|
|
249
|
+
let user = matches.docs[0];
|
|
250
|
+
// Lazy provision: the users collection's create access is false (REST POST on it is the gap
|
|
251
|
+
// this closes), so the Payload doc is minted here, after the token has already proven the email
|
|
252
|
+
// belongs to an admitted person. Users has no password at all (disableLocalStrategy), so nobody
|
|
253
|
+
// logs into Payload directly. overrideAccess: the Local API would otherwise inherit this
|
|
254
|
+
// unauthenticated req and refuse the create we just denied on REST.
|
|
255
|
+
// Unique-email race (two first sign-ins): re-find rather than fail.
|
|
256
|
+
if (!user) {
|
|
257
|
+
try {
|
|
258
|
+
user = await req.payload.create({
|
|
259
|
+
collection: "users",
|
|
260
|
+
data: { email: claims.email },
|
|
261
|
+
overrideAccess: true,
|
|
262
|
+
});
|
|
263
|
+
} catch {
|
|
264
|
+
const retry = await req.payload.find({
|
|
265
|
+
collection: "users",
|
|
266
|
+
where: { email: { equals: claims.email } },
|
|
267
|
+
limit: 1,
|
|
268
|
+
});
|
|
269
|
+
user = retry.docs[0];
|
|
270
|
+
if (!user) {
|
|
271
|
+
return new Response("Could not provision a Payload user for this account.", { status: 500 });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const collectionConfig = req.payload.collections.users.config;
|
|
277
|
+
// `req.payload.find()`'s return type has no `collection` field on each doc (it is implied by
|
|
278
|
+
// the query, not carried on the result), while `addSessionToUser` and `getFieldsToSign` type
|
|
279
|
+
// their `user` parameter as Payload's `UntypedUser`, which requires one. This doc is a "users"
|
|
280
|
+
// document (the only collection the query above searches), so the annotation is correct.
|
|
281
|
+
const authUser = { ...user, collection: "users" as const };
|
|
282
|
+
// useSessions defaults to true: a JWT without a matching entry in the user's own `sessions`
|
|
283
|
+
// array fails Payload's own auth check silently (the strategy just returns no user, bouncing to
|
|
284
|
+
// the login screen as if never signed in). This is the exact call the login operation makes
|
|
285
|
+
// before signing.
|
|
286
|
+
const { sid } = await addSessionToUser({ collectionConfig, payload: req.payload, req, user: authUser });
|
|
287
|
+
const fieldsToSign = getFieldsToSign({ collectionConfig, email: user.email, sid, user: authUser });
|
|
288
|
+
const { token: payloadToken } = await jwtSign({
|
|
289
|
+
fieldsToSign,
|
|
290
|
+
secret: req.payload.secret,
|
|
291
|
+
tokenExpiration: collectionConfig.auth.tokenExpiration,
|
|
292
|
+
});
|
|
293
|
+
const cookie = generatePayloadCookie({
|
|
294
|
+
collectionAuthConfig: collectionConfig.auth,
|
|
295
|
+
cookiePrefix: req.payload.config.cookiePrefix,
|
|
296
|
+
token: payloadToken,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Optional deep link (`to`): restricted to a path inside the content system, never a
|
|
300
|
+
// visitor-supplied absolute URL, so this cannot be turned into an open redirect.
|
|
301
|
+
const to = req.searchParams.get("to");
|
|
302
|
+
const destination = to && to.startsWith(__CMS_PREFIX__) ? to : __CMS_ADMIN__;
|
|
303
|
+
|
|
304
|
+
return new Response(null, {
|
|
305
|
+
status: 302,
|
|
306
|
+
// The base path does not auto-prefix a manually built Response's Location the way a framework
|
|
307
|
+
// link or redirect helper would, so it is set explicitly.
|
|
308
|
+
headers: { Location: destination, "Set-Cookie": cookie },
|
|
309
|
+
});
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
TS
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def users
|
|
316
|
+
<<~TS
|
|
317
|
+
__BANNER__
|
|
318
|
+
|
|
319
|
+
import type { CollectionConfig } from "payload";
|
|
320
|
+
|
|
321
|
+
import { revokeSessionOnLogout, sessionStrategy } from "../auth/sessionStrategy";
|
|
322
|
+
|
|
323
|
+
// Managed from the domain, not hand-edited: the membership list is the source of truth for who can
|
|
324
|
+
// log in. Docs here are provisioned lazily by endpoints/sso.ts on a first sign-in by an admitted
|
|
325
|
+
// person, reached only through that sign-in and never this collection's own login screen. Hidden from
|
|
326
|
+
// the admin nav (not deleted: `admin.hidden` only affects the admin UI) so nobody mistakes it for a
|
|
327
|
+
// place to hand-manage admins.
|
|
328
|
+
export const Users: CollectionConfig = {
|
|
329
|
+
slug: "users",
|
|
330
|
+
// No passwords: there is no local login, register, forgot-password or reset-password, and no
|
|
331
|
+
// hash, salt, reset or lock columns. The only way in is the session cookie endpoints/sso.ts mints,
|
|
332
|
+
// verified by sessionStrategy (Payload's own JWT strategy turns off with the local strategy).
|
|
333
|
+
auth: {
|
|
334
|
+
disableLocalStrategy: true,
|
|
335
|
+
// Payload's default is a non-Secure cookie; in production it must never ride a plain-HTTP hop.
|
|
336
|
+
cookies: { secure: process.env.NODE_ENV === "production", sameSite: "Lax" },
|
|
337
|
+
strategies: [sessionStrategy],
|
|
338
|
+
},
|
|
339
|
+
admin: {
|
|
340
|
+
hidden: true,
|
|
341
|
+
useAsTitle: "email",
|
|
342
|
+
},
|
|
343
|
+
hooks: {
|
|
344
|
+
afterLogout: [revokeSessionOnLogout],
|
|
345
|
+
},
|
|
346
|
+
// Closed: creating a user over the REST API (and Payload's first-register) would let anyone who
|
|
347
|
+
// can reach the content system mint a user and log in directly, bypassing the site's sign-in.
|
|
348
|
+
// Create is denied for REST; the sign-in endpoint's Local API create uses overrideAccess after the
|
|
349
|
+
// token has already proven the email belongs to an admitted person.
|
|
350
|
+
access: {
|
|
351
|
+
create: () => false,
|
|
352
|
+
},
|
|
353
|
+
// With the local strategy disabled Payload stops adding its own auth fields, so the two that
|
|
354
|
+
// remain are declared here, shaped exactly like Payload's so the existing columns and the
|
|
355
|
+
// users_sessions table are kept as they are.
|
|
356
|
+
fields: [
|
|
357
|
+
{
|
|
358
|
+
name: "email",
|
|
359
|
+
type: "email",
|
|
360
|
+
required: true,
|
|
361
|
+
unique: true,
|
|
362
|
+
hooks: {
|
|
363
|
+
beforeChange: [({ value }) => (value ? String(value).toLowerCase().trim() : value)],
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
name: "sessions",
|
|
368
|
+
type: "array",
|
|
369
|
+
access: {
|
|
370
|
+
read: ({ doc, req: { user } }) => user?.id === doc?.id,
|
|
371
|
+
update: () => false,
|
|
372
|
+
},
|
|
373
|
+
admin: { disabled: true },
|
|
374
|
+
fields: [
|
|
375
|
+
{ name: "id", type: "text", required: true },
|
|
376
|
+
{ name: "createdAt", type: "date", defaultValue: () => new Date() },
|
|
377
|
+
{ name: "expiresAt", type: "date", required: true },
|
|
378
|
+
],
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
};
|
|
382
|
+
TS
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../../projector"
|
|
5
|
+
require_relative "admin"
|
|
6
|
+
require_relative "routes_ts"
|
|
7
|
+
require_relative "admin_cms/templates"
|
|
8
|
+
|
|
9
|
+
module Hecks
|
|
10
|
+
module Projections
|
|
11
|
+
module Site
|
|
12
|
+
# The content system's half of a site's admin sign-in, as four TypeScript files for a Payload
|
|
13
|
+
# project: the membership check, the session strategy, the sign-in endpoint and the users
|
|
14
|
+
# collection.
|
|
15
|
+
#
|
|
16
|
+
# The site half (`SiteAdmin`) sends a signed-in admin to the content system with a short-lived
|
|
17
|
+
# token. These files accept it: the endpoint verifies the token, asks the domain host whether
|
|
18
|
+
# the person is still admitted and mints an ordinary session; the strategy asks the host again
|
|
19
|
+
# on every later request; the users collection has no passwords and cannot be created over its
|
|
20
|
+
# API. They read the same `Admin` row as the site half, so the two cannot disagree.
|
|
21
|
+
module AdminCms
|
|
22
|
+
extend Projector::Target
|
|
23
|
+
|
|
24
|
+
projects_as :site_admin_cms, emits: :files
|
|
25
|
+
|
|
26
|
+
# The files, by path relative to the directory they are written to.
|
|
27
|
+
FILES = %w[auth/membership.ts auth/sessionStrategy.ts endpoints/sso.ts collections/Users.ts].freeze
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# Renders the four files for a project that declares an admin row.
|
|
32
|
+
#
|
|
33
|
+
# @param bluebook [Bluebook::Chapter] the chapter that declares the route table
|
|
34
|
+
# @param options [Hash{Symbol => Object}] `:admin` (Admin) the checked admin row, or nil
|
|
35
|
+
# @return [Hash{String => String}] each file's relative path to its text; empty with no row
|
|
36
|
+
def call(bluebook:, options: {})
|
|
37
|
+
admin = options[:admin]
|
|
38
|
+
return {} if admin.nil?
|
|
39
|
+
|
|
40
|
+
tokens = tokens(admin)
|
|
41
|
+
FILES.to_h { |path| [path, fill(templates.fetch(path), tokens)] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param admin [Admin] a checked admin row
|
|
45
|
+
# @return [Hash{String => String}] each placeholder to the TypeScript text that replaces it
|
|
46
|
+
def tokens(admin)
|
|
47
|
+
setting = admin.setting
|
|
48
|
+
{
|
|
49
|
+
"__BANNER__" => RoutesTs::BANNER,
|
|
50
|
+
"__COOKIE__" => JSON.generate(setting.session_cookie),
|
|
51
|
+
"__HOST_ENV__" => JSON.generate(setting.host_env),
|
|
52
|
+
"__HOST_DEFAULT__" => JSON.generate(setting.host_default),
|
|
53
|
+
"__ROLES__" => RoutesTs.literal(admin.roles),
|
|
54
|
+
"__ACCOUNT_PATH__" => JSON.generate(setting.account_path),
|
|
55
|
+
"__MEMBERS_PATH__" => JSON.generate(setting.members_path),
|
|
56
|
+
"__ENDPOINT__" => JSON.generate(admin.cms_endpoint),
|
|
57
|
+
"__CMS_PREFIX__" => JSON.generate("#{setting.cms_base}/"),
|
|
58
|
+
"__CMS_ADMIN__" => JSON.generate("#{setting.cms_base}/admin"),
|
|
59
|
+
"__SSO_TARGET__" => setting.sso_target
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def fill(text, tokens) = tokens.reduce(text) { |filled, (token, value)| filled.gsub(token, value) }
|
|
64
|
+
|
|
65
|
+
def templates
|
|
66
|
+
{ "auth/membership.ts" => Templates.membership, "auth/sessionStrategy.ts" => Templates.session_strategy,
|
|
67
|
+
"endpoints/sso.ts" => Templates.sso, "collections/Users.ts" => Templates.users }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|