hecks 3.4.1 → 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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/memory/shared_elements.rb +121 -17
  3. data/lib/hecks/deploy/bluebook/deploy.bluebook +1630 -0
  4. data/lib/hecks/deploy/bluebook/deploy.hecksagon +65 -0
  5. data/lib/hecks/deploy/oidc.json +240 -0
  6. data/lib/hecks/doors/cli_runner.rb +13 -4
  7. data/lib/hecks/doors/launcher_options.rb +14 -2
  8. data/lib/hecks/hecks/adapters/bluebook_report.rb +91 -0
  9. data/lib/hecks/hecks/adapters/deploy_plans.rb +49 -0
  10. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +379 -8
  11. data/lib/hecks/hecks/adapters/first_admin.rb +8 -1
  12. data/lib/hecks/hecks/adapters/git.rb +29 -0
  13. data/lib/hecks/hecks/adapters/in_process_operations.rb +6 -2
  14. data/lib/hecks/hecks/adapters/site_toolchain.rb +8 -8
  15. data/lib/hecks/hecks/custodian.bluebook +23 -2
  16. data/lib/hecks/hecks/hecks.hecksagon +66 -0
  17. data/lib/hecks/hecks/hecks.world +6 -1
  18. data/lib/hecks/hecks/site.world +4 -2
  19. data/lib/hecks/projections/deploy/box/hosting.rb +13 -3
  20. data/lib/hecks/projections/deploy/box/roll_recipe.rb +62 -0
  21. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +10 -3
  22. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +2 -2
  23. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +16 -4
  24. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +6 -6
  25. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +1 -1
  26. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +11 -5
  27. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +8 -3
  28. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -2
  29. data/lib/hecks/projections/deploy/box.rb +26 -13
  30. data/lib/hecks/projections/site/admin.rb +140 -0
  31. data/lib/hecks/projections/site/admin_cms/templates.rb +388 -0
  32. data/lib/hecks/projections/site/admin_cms.rb +72 -0
  33. data/lib/hecks/projections/site/payload/fields.rb +234 -0
  34. data/lib/hecks/projections/site/payload/lifecycle.rb +191 -0
  35. data/lib/hecks/projections/site/payload/model.rb +128 -0
  36. data/lib/hecks/projections/site/payload/specs.rb +179 -0
  37. data/lib/hecks/projections/site/payload_driver.rb +93 -0
  38. data/lib/hecks/projections/site/root_rows.rb +59 -0
  39. data/lib/hecks/projections/site/site_admin.rb +292 -0
  40. data/lib/hecks/projections/site/site_host/templates.rb +165 -0
  41. data/lib/hecks/projections/site/site_host.rb +58 -0
  42. data/lib/hecks/projections/site/site_root.rb +179 -0
  43. data/lib/hecks/runtime/value.rb +7 -0
  44. data/lib/hecks/site/bluebook/site.bluebook +13 -2
  45. data/lib/hecks/tools/site_routes.rb +78 -8
  46. data/lib/hecks/version.rb +1 -1
  47. data/rust/host/HECKS_RELEASE +1 -1
  48. metadata +18 -2
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "table"
4
+
5
+ module Hecks
6
+ module Projections
7
+ module Site
8
+ # What a site's route table cannot say about who may use its admin pages: the session cookie,
9
+ # the host that vouches for it, the roles that count as an admin, and the two routes the
10
+ # sign-in is built from.
11
+ #
12
+ # A project declares one `member` row of a `value_object "Admin"` in the chapter that holds
13
+ # its route table. `docs/site-routes.md` lists the fields. A project that declares none has
14
+ # no admin sign-in to project.
15
+ class Admin
16
+ Setting = Struct.new(:session_cookie, :session_max_age, :host_env, :host_default, :roles,
17
+ :account_path, :members_path, :sso_token_path, :sso_target, :login,
18
+ :sso, :verdict_ttl_ms, :timeout_ms, :cms_base, keyword_init: true)
19
+
20
+ # The value object a project declares its admin row in.
21
+ OBJECT = "Admin"
22
+
23
+ # The fields the row may carry, with the Ruby class each takes.
24
+ FIELDS = {
25
+ session_cookie: String, session_max_age: Integer, host_env: String, host_default: String,
26
+ roles: String, account_path: String, members_path: String, sso_token_path: String,
27
+ sso_target: String, login: String, sso: String, verdict_ttl_ms: Integer, timeout_ms: Integer,
28
+ cms_base: String
29
+ }.freeze
30
+
31
+ # The fields the row must carry.
32
+ REQUIRED = %i[session_cookie host_env login sso].freeze
33
+
34
+ # What a field takes when the row does not carry it.
35
+ DEFAULTS = {
36
+ session_max_age: 14 * 24 * 60 * 60, host_default: "http://127.0.0.1:4322",
37
+ roles: "Admin,Owner", account_path: "/accounts/me", members_path: "/members",
38
+ sso_token_path: "/accounts/sso-token", sso_target: "/cms/api/sso",
39
+ verdict_ttl_ms: 10_000, timeout_ms: 5_000, cms_base: "/cms"
40
+ }.freeze
41
+
42
+ # The fields that name a path on the host or the content system, which start with a slash.
43
+ PATH_FIELDS = %i[account_path members_path sso_token_path sso_target cms_base].freeze
44
+
45
+ # @return [Setting] the checked row, defaults filled
46
+ attr_reader :setting
47
+
48
+ # Reads and checks the admin row a chapter declares.
49
+ #
50
+ # @param chapter [Bluebook::Chapter] the chapter that declares the route table
51
+ # @param table [Table] the checked route table
52
+ # @return [Admin, nil] the checked setting, or nil when the chapter declares none
53
+ # @raise [Table::Invalid] when the row is refused or contradicts the routes
54
+ def self.read(chapter, table:)
55
+ members = Table.rows_of(chapter, OBJECT)
56
+ return nil if members.empty?
57
+
58
+ new(members, table: table)
59
+ end
60
+
61
+ # @param members [Array<Hash{Symbol => Object}>] the declared rows
62
+ # @param table [Table] the checked route table
63
+ # @raise [Table::Invalid] naming every problem when there is one
64
+ def initialize(members, table:)
65
+ problems = []
66
+ problems << "a project declares one Admin row, not #{members.size}" if members.size > 1
67
+ row = members.first
68
+ typed = typed_fields(row, problems)
69
+ @setting = Setting.new(**DEFAULTS, **typed)
70
+ check(table.rows, problems) if problems.empty?
71
+ return if problems.empty?
72
+
73
+ raise Table::Invalid, "the admin sign-in is refused:\n#{problems.map { |line| " - #{line}" }.join("\n")}"
74
+ end
75
+
76
+ # @return [Array<String>] the roles that count as an admin
77
+ def roles = setting.roles.split(",").map(&:strip).reject(&:empty?)
78
+
79
+ # @return [String] the path of the content system's sign-in endpoint inside its own API, the
80
+ # part of `sso_target` after `<cms_base>/api`
81
+ def cms_endpoint = setting.sso_target.delete_prefix("#{setting.cms_base}/api")
82
+
83
+ private
84
+
85
+ def typed_fields(row, problems)
86
+ unknown = row.keys - FIELDS.keys
87
+ problems << "Admin row has no field #{unknown.join(', ')}; fields are #{FIELDS.keys.join(', ')}" if unknown.any?
88
+ (REQUIRED - row.keys).each { |field| problems << "Admin row needs #{field}" }
89
+ row.slice(*FIELDS.keys).select do |field, value|
90
+ value.is_a?(FIELDS.fetch(field)) ||
91
+ (problems << "Admin row has #{field} #{value.inspect}; #{field} is a #{FIELDS.fetch(field)}")
92
+ end
93
+ end
94
+
95
+ def check(rows, problems)
96
+ check_paths(problems)
97
+ check_cms_base(problems)
98
+ problems << "Admin roles name no role" if roles.empty?
99
+ check_login(rows, problems)
100
+ check_sso(rows, problems)
101
+ end
102
+
103
+ def check_paths(problems)
104
+ (PATH_FIELDS + %i[login sso]).each do |field|
105
+ value = setting[field]
106
+ problems << "Admin #{field} #{value.inspect} must start with a slash" unless value.start_with?("/")
107
+ end
108
+ end
109
+
110
+ # The sign-in endpoint is the content system's own API route, under its base path.
111
+ def check_cms_base(problems)
112
+ return if setting.sso_target.start_with?("#{setting.cms_base}/api/")
113
+
114
+ problems << "Admin sso_target #{setting.sso_target} must be under #{setting.cms_base}/api/"
115
+ end
116
+
117
+ # The login page is a public row: a visitor with no session must reach it.
118
+ def check_login(rows, problems)
119
+ row = rows.find { |candidate| candidate.path == setting.login }
120
+ if row.nil?
121
+ problems << "Admin login #{setting.login} is not a route of the table"
122
+ elsif row.auth != "public"
123
+ problems << "Admin login #{setting.login} is #{row.auth}; the login page must be public"
124
+ end
125
+ end
126
+
127
+ # The hand-off to the content system is an admin endpoint: it answers only an admin.
128
+ def check_sso(rows, problems)
129
+ row = rows.find { |candidate| candidate.path == setting.sso }
130
+ if row.nil?
131
+ problems << "Admin sso #{setting.sso} is not a route of the table"
132
+ else
133
+ problems << "Admin sso #{setting.sso} is #{row.auth}; it must be admin" unless row.auth == "admin"
134
+ problems << "Admin sso #{setting.sso} is a #{row.kind}; it must be an endpoint" unless row.kind == "endpoint"
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -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