@devdogsuga/devtools 0.1.12 → 0.1.13

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 (56) hide show
  1. package/bin/devtools-ci.mjs +15 -1
  2. package/bin/devtools.mjs +15 -1
  3. package/dist/catalog.d.ts +7 -5
  4. package/dist/catalog.js +1 -25
  5. package/dist/cf/build.d.ts +13 -0
  6. package/dist/cf/build.js +65 -0
  7. package/dist/cf/commands.js +6 -16
  8. package/dist/cf/local-env.d.ts +14 -0
  9. package/dist/cf/local-env.js +29 -0
  10. package/dist/ci.js +8 -1
  11. package/dist/cli.js +134 -138
  12. package/dist/commands.d.ts +10 -4
  13. package/dist/commands.js +40 -30
  14. package/dist/db/generated-env.d.ts +69 -0
  15. package/dist/db/generated-env.js +113 -0
  16. package/dist/db/seed-production.js +12 -6
  17. package/dist/docs/index-pages.js +2 -2
  18. package/dist/emails/commands.js +3 -3
  19. package/dist/env-entry.d.ts +49 -0
  20. package/dist/env-entry.js +204 -0
  21. package/dist/environment-doctor.d.ts +9 -0
  22. package/dist/environment-doctor.js +18 -5
  23. package/dist/environment.d.ts +2 -2
  24. package/dist/environment.js +5 -28
  25. package/dist/images/commands.d.ts +3 -6
  26. package/dist/images/commands.js +26 -30
  27. package/dist/images/events.d.ts +27 -16
  28. package/dist/images/events.js +32 -68
  29. package/dist/images/graphics.js +2 -1
  30. package/dist/images/prompts.js +2 -2
  31. package/dist/instance.d.ts +29 -27
  32. package/dist/instance.js +49 -76
  33. package/dist/launch-ci.js +5 -5
  34. package/dist/launch.d.ts +2 -1
  35. package/dist/launch.js +74 -111
  36. package/dist/menu.d.ts +17 -5
  37. package/dist/menu.js +34 -11
  38. package/dist/moderation.d.ts +48 -40
  39. package/dist/moderation.js +95 -194
  40. package/dist/oauth/wizard.d.ts +2 -2
  41. package/dist/oauth/wizard.js +6 -26
  42. package/dist/persona.d.ts +18 -0
  43. package/dist/persona.js +221 -0
  44. package/dist/repo/peers.d.ts +2 -0
  45. package/dist/repo/peers.js +3 -0
  46. package/dist/repo/supabase-project.d.ts +60 -0
  47. package/dist/repo/supabase-project.js +124 -0
  48. package/dist/setup.js +2 -2
  49. package/dist/stack.js +142 -29
  50. package/dist/telemetry.d.ts +18 -5
  51. package/dist/telemetry.js +26 -5
  52. package/dist/ui.d.ts +15 -0
  53. package/dist/ui.js +20 -0
  54. package/dist/workflows/commands.d.ts +10 -0
  55. package/dist/workflows/commands.js +119 -25
  56. package/package.json +9 -4
@@ -1,34 +1,30 @@
1
1
  /**
2
2
  * Does my app's moderation integration actually work?
3
3
  *
4
- * Two checks, and the second is the one that matters.
5
- *
6
4
  * `conformance()` asks the database what it derived from your schema: is the
7
5
  * table addressable, can an author be attributed, does quarantine have a column
8
6
  * to write to, can a client still write that column. All of it is answerable
9
7
  * from the catalog, and `platform.conformance_check()` does the answering.
10
8
  *
11
- * `quarantineRoundTrip()` answers the question the catalog cannot: *does
12
- * quarantine do anything?* Quarantine is the only moderation outcome whose
13
- * effect lives in the app's own policies rather than the platform's, so it is
14
- * the only one that can be wired up wrong while everything appears to work.
15
- * The platform records the decision, sets the column, and has no way to notice
16
- * nobody reads it. The only proof is to file, resolve, and then look.
17
- *
18
- * It runs against `platform.profile`, where quarantine means FREEZE rather than
19
- * hide: the display name is reset to the member's name of record and they lose
20
- * the ability to change it back. A profile cannot be hidden: rosters, teams
21
- * and standings all render the name, and the row is own-row-only over PostgREST
22
- * anyway, so there is nobody to hide it from. See
23
- * 20260808000000_platform_profile_moderation.sql.
9
+ * The end-to-end round trip that used to live here (`quarantineRoundTrip`,
10
+ * `moderation roundtrip`) is gone: the app repo's own CI covers that path —
11
+ * file, quarantine, and check the freeze — more thoroughly than a devtools
12
+ * command driven by hand ever did, and keeping two implementations of the
13
+ * same assertion around was a drift risk with no offsetting benefit.
24
14
  *
25
- * Both run as seeded personas over PostgREST rather than as `postgres` over
26
- * SQL, because RLS is the thing under test and a superuser bypasses it.
15
+ * Both `conformance()` and `catalog.ts`'s `readCatalog()` need to run as a
16
+ * moderator, subject to the same RLS and `canModerate` checks a real one
17
+ * would hit, rather than as `postgres` bypassing them. `withTemporaryModerator`
18
+ * below is what supplies that account: a throwaway user, created through the
19
+ * admin API and deleted again in a `finally`, rather than the seeded
20
+ * `moderator@devdogs.test` persona this package used to sign in as — that
21
+ * persona came from `supabase/seed/development/02_moderation.sql`, which only
22
+ * ever ran on a local reset and is going away along with the rest of
23
+ * `seed/development/` (see `persona.ts`'s header for the replacement).
27
24
  */
28
- import { adminClient, personaClient, PERSONA_PASSWORD, PERSONAS, } from "./instance.js";
29
- /** Runs `platform.conformance_check()` as the seeded moderator. */
30
- export async function conformance(instance, appSlug) {
31
- const client = await personaClient(instance, PERSONAS.moderator);
25
+ import { adminClient, signedInClient, } from "./instance.js";
26
+ /** Runs `platform.conformance_check()` as the given moderator client. */
27
+ export async function conformance(client, appSlug) {
32
28
  const { data, error } = await client.rpc("conformance_check", {
33
29
  app_slug: appSlug,
34
30
  });
@@ -36,190 +32,95 @@ export async function conformance(instance, appSlug) {
36
32
  throw new Error(error.message);
37
33
  return (data ?? []);
38
34
  }
39
- /** The apps that have anything to check, for the picker. */
40
- export async function listApps(instance) {
41
- const { data, error } = await adminClient(instance)
42
- .from("apps")
43
- .select("slug")
44
- .order("slug");
45
- if (error)
46
- throw new Error(error.message);
47
- return (data ?? []).map((row) => row.slug);
48
- }
49
35
  /**
50
- * Files a report against a profile, resolves it with quarantine, and checks
51
- * that the freeze and the name reset both landed.
36
+ * The role every devtools-granted moderator holds — `moderation check`'s
37
+ * throwaway account and `persona moderator`'s longer-lived one alike, so
38
+ * both resolve to the SAME role row rather than each accumulating their own.
52
39
  *
53
- * The subject is a THROWAWAY member created for the run, not one of the seeded
54
- * personas, and that is not squeamishness about touching fixtures.
55
- * `file_report` corroborates an existing open report rather than filing a
56
- * second one against the same content, and the seeds ship exactly such a report
57
- * against the author persona's profile. Reusing it would make this function's
58
- * first step silently become "add a corroboration", and every assertion after
59
- * it would be reading the seed's decision instead of its own.
40
+ * Reuses the id `supabase/seed/development/02_moderation.sql` used to seed
41
+ * this same role under, on the theory that an existing local database that
42
+ * still has that seeded row should be upserted into agreement rather than
43
+ * given a second, redundant "Moderator" role.
44
+ */
45
+ export const MODERATOR_ROLE_ID = "00000000-0000-4000-9000-000000000001";
46
+ /**
47
+ * Creates (or updates) the Moderator role definition, and returns its id.
60
48
  *
61
- * Everything it creates, it deletes, including on the failure paths, which is
62
- * why the teardown is in a `finally`. A contributor running this twice should
63
- * get the same answer the second time.
49
+ * `canModerate: true` and nothing else — not Root, not any of the other
50
+ * `roles` permission columns — mirroring exactly what the seed persona held.
51
+ * Idempotent (`upsert`) so repeated calls, from either caller above, never
52
+ * race each other into two rows or fail on the second one.
64
53
  */
65
- export async function quarantineRoundTrip(instance) {
66
- const steps = [];
54
+ export async function ensureModeratorRole(admin) {
55
+ const { error } = await admin.from("roles").upsert({
56
+ id: MODERATOR_ROLE_ID,
57
+ title: "Moderator",
58
+ description: "Created by `pnpm devtools`: works the report queue, and nothing else.",
59
+ roleType: "custom",
60
+ rank: 5000,
61
+ canModerate: true,
62
+ });
63
+ if (error) {
64
+ throw new Error(`Could not create the Moderator role: ${error.message}`);
65
+ }
66
+ return MODERATOR_ROLE_ID;
67
+ }
68
+ /** Grants the Moderator role to `userId`, creating the role definition first
69
+ * if this is the first account to need it. */
70
+ export async function grantModerator(admin, userId) {
71
+ const roleId = await ensureModeratorRole(admin);
72
+ const { error } = await admin.from("userRoles").insert({ userId, roleId });
73
+ if (error) {
74
+ throw new Error(`Could not grant the Moderator role: ${error.message}`);
75
+ }
76
+ }
77
+ /** An email that unambiguously belongs to devtools, not a real account —
78
+ * see `persona.ts`'s header for the `@persona.test` convention this shares. */
79
+ function temporaryModeratorEmail() {
80
+ return `devtools-moderator-${crypto.randomUUID().slice(0, 8)}@persona.test`;
81
+ }
82
+ /** A password nobody needs to remember — this account lives for the
83
+ * duration of one command. */
84
+ function randomPassword() {
85
+ return crypto.randomUUID() + crypto.randomUUID();
86
+ }
87
+ /**
88
+ * Runs `fn` with a client signed in as a throwaway moderator, created for
89
+ * this call and deleted again in a `finally` whether or not `fn` throws.
90
+ *
91
+ * The admin API's `createUser` bypasses the app's `before_user_created`
92
+ * uga.edu hook (that hook only runs on the sign-up/OTP paths a real user
93
+ * takes, not the service-role admin API), which is why a `@persona.test`
94
+ * address works here at all — see `persona.ts`'s header for the same note
95
+ * in more detail, since that is the command a contributor is more likely to
96
+ * read first.
97
+ */
98
+ export async function withTemporaryModerator(instance, fn) {
67
99
  const admin = adminClient(instance);
68
- const email = `roundtrip-${crypto.randomUUID().slice(0, 8)}@devdogs.test`;
69
- const abusiveName = "BUY CHEAP FOLLOWERS NOW";
70
- let subjectId = null;
71
- let reportId = null;
100
+ const email = temporaryModeratorEmail();
101
+ const password = randomPassword();
102
+ let userId = null;
72
103
  try {
73
- // ── Arrange: a member with an abusive display name and a name of record ──
74
- //
75
- // Through the GoTrue admin API rather than `from("users")`: the `auth`
76
- // schema is not exposed over PostgREST, and should not be.
77
- const { data: created, error: createErr } = await admin.auth.admin.createUser({
104
+ const { data, error } = await admin.auth.admin.createUser({
78
105
  email,
79
- password: PERSONA_PASSWORD,
106
+ password,
80
107
  email_confirm: true,
108
+ user_metadata: { createdBy: "devtools moderation check" },
81
109
  });
82
- if (createErr ?? !created.user) {
83
- throw new Error(`Could not create the subject account: ${createErr?.message}`);
84
- }
85
- subjectId = created.user.id;
86
- const { error: profileErr } = await admin.from("profile").insert({
87
- userId: subjectId,
88
- preferredName: abusiveName,
89
- bio: "Created by `pnpm devtools`, and deleted again immediately.",
90
- // The name the reset restores. Written by the Involvement roster import
91
- // in production (server/actions/verification.ts); set by hand here so the
92
- // round-trip exercises the branch that has something to restore.
93
- legalFirstName: "Round",
94
- legalLastName: "Trip",
95
- });
96
- if (profileErr) {
97
- throw new Error(`Could not create a profile: ${profileErr.message}`);
98
- }
99
- steps.push({
100
- name: "fixture",
101
- ok: true,
102
- detail: `Created a profile named "${abusiveName}" with a name of record.`,
103
- });
104
- // ── Editable before ─────────────────────────────────────────────────────
105
- //
106
- // Establishes the baseline the freeze is measured against. Without it a
107
- // profile that was never editable in the first place would pass the freeze
108
- // check for the wrong reason.
109
- const subject = await personaClient(instance, email);
110
- await subject
111
- .from("profile")
112
- .update({ bio: "Edited." })
113
- .eq("userId", subjectId);
114
- const { data: beforeRow } = await admin
115
- .from("profile")
116
- .select("bio")
117
- .eq("userId", subjectId)
118
- .single();
119
- const editableBefore = beforeRow?.bio === "Edited.";
120
- steps.push({
121
- name: "editable_before",
122
- ok: editableBefore,
123
- detail: editableBefore
124
- ? "The member can edit their own profile before it is reported."
125
- : "The member could NOT edit their own profile before it was reported — the update policy is denying more than quarantine.",
126
- });
127
- // ── Act: file a report as the seeded member ─────────────────────────────
128
- const memberClient = await personaClient(instance, PERSONAS.member);
129
- // A literal, not a lookup. The vocabulary is a platform-owned enum, so
130
- // there is no per-app row to find first. If this label ever stops existing,
131
- // Postgres rejects the call by type rather than silently reporting "no
132
- // reasons configured".
133
- const { data: filed, error: fileErr } = await memberClient.rpc("file_report", {
134
- app_slug: "platform",
135
- content_type: "profile",
136
- content_ref: subjectId,
137
- reason: "spam",
138
- description: "Filed by the devtools round-trip.",
139
- });
140
- if (fileErr)
141
- throw new Error(`file_report failed: ${fileErr.message}`);
142
- // A one-element array, not an object: these RPCs return `setof` now, so
143
- // PostgREST serialises even a single result as a list.
144
- const [row] = (filed ?? []);
145
- if (!row)
146
- throw new Error("file_report returned no rows");
147
- reportId = row.reportId;
148
- steps.push({
149
- name: "file_report",
150
- ok: true,
151
- detail: `A member filed a report against the profile (${reportId}).`,
152
- });
153
- // ── Act: resolve with quarantine as the moderator ───────────────────────
154
- const moderator = await personaClient(instance, PERSONAS.moderator);
155
- const { error: resolveErr } = await moderator.rpc("resolve_report", {
156
- report_id: reportId,
157
- subject_action: "no_action",
158
- filer_action: "no_action",
159
- content_action: "quarantine",
160
- moderator_note: "devtools round-trip",
161
- });
162
- if (resolveErr) {
163
- throw new Error(`resolve_report failed: ${resolveErr.message}`);
110
+ if (error || !data.user) {
111
+ throw new Error(`Could not create a temporary moderator: ${error?.message}`);
164
112
  }
165
- steps.push({
166
- name: "resolve_report",
167
- ok: true,
168
- detail: "A moderator resolved the report with `quarantine`.",
169
- });
170
- // ── Assert: the effect actually landed ──────────────────────────────────
171
- const { data: after } = await admin
172
- .from("profile")
173
- .select("quarantinedBy, preferredName")
174
- .eq("userId", subjectId)
175
- .single();
176
- const columnSet = Boolean(after?.quarantinedBy);
177
- steps.push({
178
- name: "quarantine_column",
179
- ok: columnSet,
180
- detail: columnSet
181
- ? 'The resolution set "quarantinedBy" on the row.'
182
- : 'The resolution did NOT set "quarantinedBy" — apply_content_action did not reach this table.',
183
- });
184
- const nameReset = after?.preferredName === "Round Trip";
185
- steps.push({
186
- name: "name_reset",
187
- ok: nameReset,
188
- detail: nameReset
189
- ? 'The display name was reset to the name of record ("Round Trip").'
190
- : `The display name is still "${String(after?.preferredName)}" — the reset trigger did not fire, so the remedy is a record with no effect.`,
191
- });
192
- // ── Assert: and that the member stopped being able to undo it ───────────
193
- //
194
- // The check that matters. A denied UPDATE under RLS is not an error, it
195
- // simply matches no rows, so asserting on `error` here would pass just as
196
- // happily if the write had landed. Read the value back instead.
197
- await subject
198
- .from("profile")
199
- .update({ preferredName: abusiveName })
200
- .eq("userId", subjectId);
201
- const { data: frozenRow } = await admin
202
- .from("profile")
203
- .select("preferredName")
204
- .eq("userId", subjectId)
205
- .single();
206
- const frozen = frozenRow?.preferredName !== abusiveName;
207
- steps.push({
208
- name: "frozen_after",
209
- ok: frozen,
210
- detail: frozen
211
- ? "The member can no longer change their display name back."
212
- : 'The member CHANGED IT STRAIGHT BACK. The update policy does not filter on "quarantinedBy" — this is the check that matters.',
213
- });
214
- return steps;
113
+ userId = data.user.id;
114
+ await grantModerator(admin, userId);
115
+ const client = await signedInClient(instance, email, password);
116
+ return await fn(client);
215
117
  }
216
118
  finally {
217
- // Teardown runs whether or not the assertions held. Deleting the report
218
- // first lets the resolution cascade, which releases the FK on the profile;
219
- // deleting the auth user then cascades to the profile row itself.
220
- if (reportId)
221
- await admin.from("reports").delete().eq("id", reportId);
222
- if (subjectId)
223
- await admin.auth.admin.deleteUser(subjectId);
119
+ // `userRoles.userId` cascades on delete (see the migration that defines
120
+ // it), so deleting the auth user is the whole teardown — no separate
121
+ // `userRoles` row to clean up, and the Moderator role DEFINITION is left
122
+ // in place for the next caller, same as `ensureModeratorRole` intends.
123
+ if (userId)
124
+ await admin.auth.admin.deleteUser(userId);
224
125
  }
225
126
  }
@@ -20,8 +20,8 @@ import { type ConnectTarget } from "./target.js";
20
20
  */
21
21
  export declare function connectViaOneClick(target: ConnectTarget, platformUrl: string, cwd: string, transportOverride: ConnectTransport | undefined): Promise<ExchangeResult>;
22
22
  /**
23
- * The wizard. `baseUrlOverride`/`platformUrlOverride`, when given, skip
24
- * their respective prompts (only reachable via "Paste credentials instead"
23
+ * The wizard. `baseUrlOverride`/`platformUrlOverride`, when given, replace
24
+ * the canonical API and platform URLs (used by "Paste credentials instead"
25
25
  * and the one-click flow respectively). `transportOverride` (`--device`/
26
26
  * `--loopback`) forces the one-click flow's transport — see
27
27
  * `connectViaOneClick` — and is ignored on the "Paste credentials instead"
@@ -33,7 +33,6 @@ import { DeviceError, PollError, pollForToken, requestDeviceCode, } from "./devi
33
33
  import { decideTransport } from "./transport.js";
34
34
  import { defaultLabel } from "./label.js";
35
35
  import { HostedCredentialsMissingError, NoTierResolvedError, readHostedTargetFromEnvFiles, resolveLocalTarget, resolveHostedTargetInRepo, } from "./target.js";
36
- import { recordResolved } from "../invocation.js";
37
36
  import { loadEnvSession } from "../repo/peers.js";
38
37
  import { discoverRepoRoot } from "../repo/root.js";
39
38
  import { resolveTier } from "../tier.js";
@@ -340,30 +339,11 @@ export async function connectViaOneClick(target, platformUrl, cwd, transportOver
340
339
  }
341
340
  return connectOneClick(target, platformUrl, cwd, decision.reason, listener);
342
341
  }
343
- /** Step 2 (paste branch): the original manual flow — DevDogs API URL, then client ID/secret. */
342
+ /** Step 2 (paste branch): the original manual flow — client ID/secret against the DevDogs API. */
344
343
  async function connectByPasting(baseUrlOverride) {
345
- let baseUrl = baseUrlOverride ?? process.env[ENV_KEYS.baseUrl] ?? DEFAULT_API_URL;
346
- if (!baseUrlOverride) {
347
- baseUrl = unwrap(await text({
348
- message: "DevDogs API URL",
349
- initialValue: baseUrl,
350
- validate: (v) => {
351
- if (!v)
352
- return;
353
- try {
354
- new URL(v);
355
- }
356
- catch {
357
- return "Enter a valid URL (e.g. https://api.devdogsuga.org)";
358
- }
359
- },
360
- }));
361
- }
362
- baseUrl = baseUrl.replace(/\/+$/, "");
363
- // A base URL entered at the prompt (not passed as --base-url) is what the
364
- // rerun line needs to skip this step next time.
365
- if (!baseUrlOverride)
366
- recordResolved("--base-url", baseUrl);
344
+ // Not prompted: contributors always want the canonical API. `--base-url` /
345
+ // `OAUTH_BASE_URL` stay as overrides for testing against another project.
346
+ const baseUrl = (baseUrlOverride ?? process.env[ENV_KEYS.baseUrl] ?? DEFAULT_API_URL).replace(/\/+$/, "");
367
347
  let clientId = process.env[ENV_KEYS.clientId] || undefined;
368
348
  let clientSecret = process.env[ENV_KEYS.clientSecret] || undefined;
369
349
  if (clientId && clientSecret) {
@@ -414,8 +394,8 @@ async function connectByPasting(baseUrlOverride) {
414
394
  return { clientId, clientSecret, issuer };
415
395
  }
416
396
  /**
417
- * The wizard. `baseUrlOverride`/`platformUrlOverride`, when given, skip
418
- * their respective prompts (only reachable via "Paste credentials instead"
397
+ * The wizard. `baseUrlOverride`/`platformUrlOverride`, when given, replace
398
+ * the canonical API and platform URLs (used by "Paste credentials instead"
419
399
  * and the one-click flow respectively). `transportOverride` (`--device`/
420
400
  * `--loopback`) forces the one-click flow's transport — see
421
401
  * `connectViaOneClick` — and is ignored on the "Paste credentials instead"
@@ -0,0 +1,18 @@
1
+ import type { DbConnection } from "./db/connection.js";
2
+ export declare const PERSONA_KINDS: readonly ["member", "moderator"];
3
+ export type PersonaKind = (typeof PERSONA_KINDS)[number];
4
+ export declare function isPersonaKind(value: string): value is PersonaKind;
5
+ /**
6
+ * Refuses outside development — a staging or production database has real
7
+ * members on it, and this command exists to populate one that does not.
8
+ * Exported so `cli.ts`'s `moderation check` (also development-only now, for
9
+ * the same reason) shares the exact refusal wording.
10
+ */
11
+ export declare function refuseUnlessDevelopment(connection: DbConnection, label: string): boolean;
12
+ export interface CreatedPersona {
13
+ email: string;
14
+ password: string;
15
+ userId: string;
16
+ preferredName: string;
17
+ }
18
+ export declare function runPersona(argv: string[]): Promise<void>;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `persona <member|moderator>` / `persona --clean` — throwaway accounts for
3
+ * a development database.
4
+ *
5
+ * These replace the seeded `member@devdogs.test` / `author@devdogs.test` /
6
+ * `moderator@devdogs.test` personas that used to come from
7
+ * `supabase/seed/development/02_moderation.sql`. That file — and the rest of
8
+ * `supabase/seed/development/` — is going away in the app repo, so a fresh
9
+ * `db reset` no longer leaves a signed-in account to switch to; this is what
10
+ * creates one on demand instead.
11
+ *
12
+ * Every account this creates lives at `<kind>-<8 hex>@persona.test`: a
13
+ * domain that cannot collide with a real UGA address, and — not
14
+ * coincidentally — the same reason it can exist here at all. `.createUser`
15
+ * goes through GoTrue's ADMIN API, which inserts the row directly rather
16
+ * than running the app's `before_user_created` Auth Hook (the one that
17
+ * requires a uga.edu address on the sign-up/OTP paths a real member takes).
18
+ * The admin API was never wired to that hook, so a `@persona.test` address
19
+ * sails through here uncontested.
20
+ *
21
+ * `--clean` deletes every account this command created — matched on BOTH the
22
+ * `@persona.test` domain AND a `user_metadata.createdBy` marker, never the
23
+ * domain alone. A shared development database can carry `@persona.test`
24
+ * accounts this command did not create — CI's own fixtures, most likely, or
25
+ * a `moderation check` run whose `withTemporaryModerator` teardown never
26
+ * reached its `finally` because the process was killed. The marker is what
27
+ * keeps `--clean` from reaching past accounts it is not responsible for.
28
+ */
29
+ import { randomUUID } from "node:crypto";
30
+ import { confirm, log, note, select } from "@clack/prompts";
31
+ import { adminClient, resolveInstance, } from "./instance.js";
32
+ import { grantModerator } from "./moderation.js";
33
+ import { explain, unwrap } from "./ui.js";
34
+ export const PERSONA_KINDS = ["member", "moderator"];
35
+ export function isPersonaKind(value) {
36
+ return PERSONA_KINDS.includes(value);
37
+ }
38
+ /** Every account this command creates carries this in `user_metadata`, and
39
+ * `--clean` refuses to delete anything without it — see the header. */
40
+ const CREATED_BY = "devtools persona";
41
+ function personaEmail(kind) {
42
+ return `${kind}-${randomUUID().slice(0, 8)}@persona.test`;
43
+ }
44
+ /** Nobody needs to remember this — it is printed once, for whoever asked. */
45
+ function randomPassword() {
46
+ return randomUUID() + randomUUID();
47
+ }
48
+ /**
49
+ * Refuses outside development — a staging or production database has real
50
+ * members on it, and this command exists to populate one that does not.
51
+ * Exported so `cli.ts`'s `moderation check` (also development-only now, for
52
+ * the same reason) shares the exact refusal wording.
53
+ */
54
+ export function refuseUnlessDevelopment(connection, label) {
55
+ if (connection.tier === "development")
56
+ return false;
57
+ process.stderr.write(`${label}: only runs against a development database — this session ` +
58
+ `targets ${connection.tier}. Relaunch with --tier development:local ` +
59
+ "or development:remote.\n");
60
+ return true;
61
+ }
62
+ /**
63
+ * Creates the account and its profile — `userId` and `preferredName` are the
64
+ * only two columns `platform.profile` requires (see the migration; every
65
+ * other column is nullable or defaulted), which is why this needs nothing
66
+ * from `02_moderation.sql`'s richer INSERT beyond the shape of the two rows
67
+ * it wrote. Grants the Moderator role too, for `kind === "moderator"`.
68
+ */
69
+ async function createPersona(instance, kind) {
70
+ const admin = adminClient(instance);
71
+ const email = personaEmail(kind);
72
+ const password = randomPassword();
73
+ const preferredName = kind === "moderator" ? "Devtools Moderator" : "Devtools Member";
74
+ const { data, error } = await admin.auth.admin.createUser({
75
+ email,
76
+ password,
77
+ email_confirm: true,
78
+ user_metadata: { createdBy: CREATED_BY },
79
+ });
80
+ if (error || !data.user) {
81
+ throw new Error(`Could not create the account: ${error?.message}`);
82
+ }
83
+ const userId = data.user.id;
84
+ const { error: profileErr } = await admin
85
+ .from("profile")
86
+ .insert({ userId, preferredName });
87
+ if (profileErr) {
88
+ throw new Error(`Could not create a profile: ${profileErr.message}`);
89
+ }
90
+ if (kind === "moderator")
91
+ await grantModerator(admin, userId);
92
+ return { email, password, userId, preferredName };
93
+ }
94
+ /** Any OTHER profile on this database, for a moderator persona to report —
95
+ * `null` when this is the only account around, which the caller reports
96
+ * rather than guessing at a subject that does not exist. */
97
+ async function findOtherProfile(instance, excludeUserId) {
98
+ const { data, error } = await adminClient(instance)
99
+ .from("profile")
100
+ .select("userId, preferredName")
101
+ .neq("userId", excludeUserId)
102
+ .limit(1)
103
+ .maybeSingle();
104
+ if (error)
105
+ throw new Error(`Could not read platform.profile: ${error.message}`);
106
+ return data;
107
+ }
108
+ /**
109
+ * Files one open report, mirroring the "one open report" `02_moderation.sql`
110
+ * used to seed — inserted directly rather than through
111
+ * `platform.file_report()`, the same reasoning that seed's own comment gave:
112
+ * neither a seed nor this command has a session to attribute the RPC call to.
113
+ */
114
+ async function fileSampleReport(instance, reporterId, subjectId, subjectName) {
115
+ const admin = adminClient(instance);
116
+ const { data: app, error: appErr } = await admin
117
+ .from("apps")
118
+ .select("id")
119
+ .eq("slug", "platform")
120
+ .single();
121
+ if (appErr || !app) {
122
+ throw new Error(`Could not find the platform app row: ${appErr?.message}`);
123
+ }
124
+ const { error } = await admin.from("reports").insert({
125
+ appId: app.id,
126
+ reporterUserId: reporterId,
127
+ reportedUserId: subjectId,
128
+ contentType: "profile",
129
+ contentRef: subjectId,
130
+ contentSnapshot: subjectName,
131
+ description: "Filed by `pnpm devtools persona` so the moderation queue has something in it.",
132
+ reason: "spam",
133
+ });
134
+ if (error) {
135
+ throw new Error(`Could not file the sample report: ${error.message}`);
136
+ }
137
+ }
138
+ async function runClean(instance) {
139
+ const admin = adminClient(instance);
140
+ const { data, error } = await admin.auth.admin.listUsers();
141
+ if (error)
142
+ throw new Error(`Could not list accounts: ${error.message}`);
143
+ // Both conditions — see the header on why the domain alone is not enough.
144
+ const targets = data.users.filter((u) => u.email?.endsWith("@persona.test") &&
145
+ u.user_metadata?.createdBy === CREATED_BY);
146
+ if (targets.length === 0) {
147
+ log.info("No `pnpm devtools persona`-created accounts to clean up.");
148
+ return;
149
+ }
150
+ // Deleting the auth user cascades to its profile, its Moderator role grant
151
+ // (if any) and any reports it filed or was reported in — every FK a
152
+ // persona account touches is `on delete cascade` — so there is nothing
153
+ // else to clean up per account.
154
+ for (const user of targets) {
155
+ await admin.auth.admin.deleteUser(user.id);
156
+ log.success(`Deleted ${user.email}.`);
157
+ }
158
+ log.info(`Deleted ${targets.length} account${targets.length === 1 ? "" : "s"}.`);
159
+ }
160
+ export async function runPersona(argv) {
161
+ const clean = argv.includes("--clean");
162
+ const positional = argv.find((arg) => !arg.startsWith("--"));
163
+ if (clean && positional) {
164
+ explain("Two answers to one question.", "--clean deletes every account this command made; a kind creates a new one.", ["pnpm devtools persona member", "pnpm devtools persona --clean"]);
165
+ process.exitCode = 1;
166
+ return;
167
+ }
168
+ const resolved = await resolveInstance({ label: "devtools persona" });
169
+ if (!resolved) {
170
+ process.exitCode = 1;
171
+ return;
172
+ }
173
+ const { connection, instance } = resolved;
174
+ if (refuseUnlessDevelopment(connection, "devtools persona")) {
175
+ process.exitCode = 1;
176
+ return;
177
+ }
178
+ if (clean) {
179
+ await runClean(instance);
180
+ return;
181
+ }
182
+ let kind;
183
+ if (positional) {
184
+ if (!isPersonaKind(positional)) {
185
+ explain(`"${positional}" is not a persona kind.`, "", [
186
+ "pnpm devtools persona member",
187
+ "pnpm devtools persona moderator",
188
+ ]);
189
+ process.exitCode = 1;
190
+ return;
191
+ }
192
+ kind = positional;
193
+ }
194
+ else {
195
+ kind = unwrap(await select({
196
+ message: "Which kind of persona?",
197
+ options: PERSONA_KINDS.map((value) => ({ value, label: value })),
198
+ }));
199
+ }
200
+ const persona = await createPersona(instance, kind);
201
+ note(`Email: ${persona.email}\nPassword: ${persona.password}`, `${kind} persona created`);
202
+ const fileReport = unwrap(await confirm({
203
+ message: "File a sample report against a member profile, so the moderation queue isn't empty?",
204
+ initialValue: kind === "moderator",
205
+ }));
206
+ if (!fileReport)
207
+ return;
208
+ // A member persona reports itself — there is no separate reporter account
209
+ // on offer here, and nothing downstream cares who filed it, only that the
210
+ // queue has one open report to practice resolving. A moderator persona
211
+ // reports whatever OTHER profile already exists.
212
+ const subject = kind === "member"
213
+ ? { userId: persona.userId, preferredName: persona.preferredName }
214
+ : await findOtherProfile(instance, persona.userId);
215
+ if (!subject) {
216
+ log.warn("No other account to report yet — run `pnpm devtools persona member` first.");
217
+ return;
218
+ }
219
+ await fileSampleReport(instance, persona.userId, subject.userId, subject.preferredName);
220
+ log.success("Filed a sample report. Resolve it from /console/moderation.");
221
+ }
@@ -35,3 +35,5 @@ import type * as DbTypegenModule from "@devdogsuga/db/typegen";
35
35
  export declare function loadDbTypegen(): Promise<typeof DbTypegenModule>;
36
36
  import type * as BrandEventModule from "@devdogsuga/brand/event";
37
37
  export declare function loadBrandEvent(): Promise<typeof BrandEventModule>;
38
+ import type * as EventsModule from "@devdogsuga/events";
39
+ export declare function loadEvents(): Promise<typeof EventsModule>;
@@ -129,6 +129,9 @@ export function loadDbTypegen() {
129
129
  export function loadBrandEvent() {
130
130
  return loadPeer("@devdogsuga/brand/event");
131
131
  }
132
+ export function loadEvents() {
133
+ return loadPeer("@devdogsuga/events");
134
+ }
132
135
  // `@devdogsuga/newsletter` used to be an optional peer here, read by
133
136
  // devtools' own `newsletter` command. Wave 2, stage A2 moved that command to
134
137
  // a plain Backstage script (`packages/newsletter-cli`) — see its README —