@devdogsuga/devtools 0.1.11 → 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.
- package/bin/devtools-ci.mjs +15 -1
- package/bin/devtools.mjs +15 -1
- package/dist/catalog.d.ts +7 -5
- package/dist/catalog.js +1 -25
- package/dist/cf/build.d.ts +13 -0
- package/dist/cf/build.js +65 -0
- package/dist/cf/commands.js +6 -16
- package/dist/cf/local-env.d.ts +14 -0
- package/dist/cf/local-env.js +29 -0
- package/dist/ci.js +8 -1
- package/dist/cli.js +134 -138
- package/dist/commands.d.ts +10 -4
- package/dist/commands.js +40 -30
- package/dist/db/generated-env.d.ts +69 -0
- package/dist/db/generated-env.js +113 -0
- package/dist/db/seed-production.js +12 -6
- package/dist/docs/index-pages.js +2 -2
- package/dist/emails/commands.js +3 -3
- package/dist/env/discovery.js +19 -6
- package/dist/env-entry.d.ts +49 -0
- package/dist/env-entry.js +204 -0
- package/dist/environment-doctor.d.ts +9 -0
- package/dist/environment-doctor.js +18 -5
- package/dist/environment.d.ts +2 -2
- package/dist/environment.js +5 -28
- package/dist/images/commands.d.ts +3 -6
- package/dist/images/commands.js +26 -30
- package/dist/images/events.d.ts +27 -16
- package/dist/images/events.js +32 -68
- package/dist/images/graphics.js +2 -1
- package/dist/images/prompts.js +2 -2
- package/dist/instance.d.ts +29 -27
- package/dist/instance.js +49 -76
- package/dist/launch-ci.js +5 -5
- package/dist/launch.d.ts +2 -1
- package/dist/launch.js +74 -111
- package/dist/menu.d.ts +17 -5
- package/dist/menu.js +34 -11
- package/dist/moderation.d.ts +48 -40
- package/dist/moderation.js +95 -194
- package/dist/oauth/wizard.d.ts +2 -2
- package/dist/oauth/wizard.js +6 -26
- package/dist/persona.d.ts +18 -0
- package/dist/persona.js +221 -0
- package/dist/repo/peer-redirect-hooks.d.ts +16 -0
- package/dist/repo/peer-redirect-hooks.js +10 -0
- package/dist/repo/peer-redirect.d.ts +4 -0
- package/dist/repo/peer-redirect.js +26 -0
- package/dist/repo/peers.d.ts +15 -0
- package/dist/repo/peers.js +26 -1
- package/dist/repo/supabase-project.d.ts +60 -0
- package/dist/repo/supabase-project.js +124 -0
- package/dist/setup.js +2 -2
- package/dist/stack.js +142 -29
- package/dist/telemetry.d.ts +18 -5
- package/dist/telemetry.js +26 -5
- package/dist/ui.d.ts +15 -0
- package/dist/ui.js +20 -0
- package/dist/workflows/commands.d.ts +10 -0
- package/dist/workflows/commands.js +119 -25
- package/package.json +10 -5
package/dist/moderation.js
CHANGED
|
@@ -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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
26
|
-
*
|
|
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,
|
|
29
|
-
/** Runs `platform.conformance_check()` as the
|
|
30
|
-
export async function conformance(
|
|
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
|
-
*
|
|
51
|
-
*
|
|
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
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
66
|
-
const
|
|
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 =
|
|
69
|
-
const
|
|
70
|
-
let
|
|
71
|
-
let reportId = null;
|
|
100
|
+
const email = temporaryModeratorEmail();
|
|
101
|
+
const password = randomPassword();
|
|
102
|
+
let userId = null;
|
|
72
103
|
try {
|
|
73
|
-
|
|
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
|
|
106
|
+
password,
|
|
80
107
|
email_confirm: true,
|
|
108
|
+
user_metadata: { createdBy: "devtools moderation check" },
|
|
81
109
|
});
|
|
82
|
-
if (
|
|
83
|
-
throw new Error(`Could not create
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
}
|
package/dist/oauth/wizard.d.ts
CHANGED
|
@@ -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,
|
|
24
|
-
*
|
|
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"
|
package/dist/oauth/wizard.js
CHANGED
|
@@ -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 —
|
|
342
|
+
/** Step 2 (paste branch): the original manual flow — client ID/secret against the DevDogs API. */
|
|
344
343
|
async function connectByPasting(baseUrlOverride) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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,
|
|
418
|
-
*
|
|
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>;
|
package/dist/persona.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-loader hooks for `repo/peer-redirect.ts`. Runs on Node's loader
|
|
3
|
+
* thread, not the main one, so nothing here can import devtools state: the
|
|
4
|
+
* redirects arrive once, as `initialize()`'s data, and are never updated.
|
|
5
|
+
*/
|
|
6
|
+
import type { InitializeHook, ResolveHook } from "node:module";
|
|
7
|
+
export interface PeerRedirect {
|
|
8
|
+
/** The exact file URL of the importing module the redirect applies to. */
|
|
9
|
+
parentURL: string;
|
|
10
|
+
/** The bare specifier it imports, e.g. `@devdogsuga/env`. */
|
|
11
|
+
specifier: string;
|
|
12
|
+
/** Where that specifier should resolve instead. */
|
|
13
|
+
url: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const initialize: InitializeHook<PeerRedirect[]>;
|
|
16
|
+
export declare const resolve: ResolveHook;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
let redirects = [];
|
|
2
|
+
export const initialize = (data) => {
|
|
3
|
+
redirects = data;
|
|
4
|
+
};
|
|
5
|
+
export const resolve = (specifier, context, nextResolve) => {
|
|
6
|
+
const redirect = redirects.find((r) => r.specifier === specifier && r.parentURL === context.parentURL);
|
|
7
|
+
if (redirect)
|
|
8
|
+
return { url: redirect.url, shortCircuit: true };
|
|
9
|
+
return nextResolve(specifier, context);
|
|
10
|
+
};
|