@bongos/core 1.24.4 → 1.24.6
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/.bongos-core.json +99 -59
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +8 -0
- package/docs/api/openapi.json +110 -5
- package/docs/api-reference.md +6 -3
- package/docs/copy-inventory.md +47 -43
- package/docs/copy-registry.json +81 -45
- package/docs/file-map.md +3 -2
- package/docs/module-api-changelog.md +4 -2
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +64 -56
- package/migrations/core_286_credit_log_no_spend.sql +128 -0
- package/modules/copy-desk/auto-close.js +51 -11
- package/modules/copy-desk/page-status.js +18 -6
- package/modules/copy-desk/tests/copy_no_cms.mjs +8 -1
- package/modules/copy-desk/tests/copy_page_auto_close.mjs +68 -6
- package/modules/copy-desk/tests/copy_page_status.mjs +5 -1
- package/modules/hall-ui/public/tweak-editor.js +2 -1
- package/modules/lifecycle/db-claims.js +2 -2
- package/modules/lifecycle/db-ship.js +2 -2
- package/modules/lifecycle/routes/claims.js +7 -1
- package/modules/platform-identity/account-logins.js +131 -0
- package/modules/platform-identity/migrations/platform_identity_035_account_login_email_idx.sql +19 -0
- package/modules/platform-identity/routes/profile.js +29 -1
- package/modules/platform-identity/routes/sso.js +25 -3
- package/modules/public-landing/public/account.html +114 -0
- package/modules/public-landing/public/account.states.json +1 -0
- package/modules/ui-design/kit/serve.js +24 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +39 -1
- package/scripts/bongos/codemod-rename-scripts-gds.js +1 -3
- package/scripts/bongos/dev-box-guard.js +0 -1
- package/scripts/bongos/publish-manifest.js +1 -1
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/src/bongos/agent-standing-rules.js +44 -0
- package/src/bongos/auth-github.js +24 -7
- package/src/bongos/module-scope-map.js +3 -2
- package/src/bongos/routes/auth-google.js +71 -5
- package/src/bongos/routes/auth-link.js +132 -0
- package/src/bongos/routes/me.js +4 -1
- package/src/bongos/routes.js +2 -0
- package/src/bongos/sign-in-pages.js +20 -1
- package/src/module-api.js +1 -1
- package/tests/account_linking.mjs +289 -0
- package/tests/account_logins_db.mjs +56 -0
- package/tests/agent_standing_rules.mjs +142 -0
- package/tests/claims_batch_routes.mjs +115 -25
- package/tests/copy_desk_page_release.mjs +20 -0
- package/tests/copy_stuck_page_tweaks.mjs +5 -3
- package/tests/credit_log_no_spend_db.mjs +160 -0
- package/tests/effective_visibility_predicate.mjs +8 -0
- package/tests/government_laws_session.mjs +2 -1
- package/tests/hall_tweak_editor.mjs +11 -0
- package/tests/host_topology_skips.mjs +2 -0
- package/tests/hub_account_page.mjs +91 -5
- package/tests/run_node_reroute.mjs +136 -0
|
@@ -33,7 +33,7 @@ const ROUNDS = {
|
|
|
33
33
|
blank: round({ id: '11' }),
|
|
34
34
|
empty_batch: round({ id: '12', description: `${draft}\n\n${batch([])}` }),
|
|
35
35
|
real_batch: round({ id: '13', description: `${draft}\n\n${batch([line])}` }),
|
|
36
|
-
draft_only: round({ id: '14', description: draft }),
|
|
36
|
+
draft_only: round({ id: '14', description: draft, updated_at: new Date(NOW - 12 * 3600_000).toISOString() }),
|
|
37
37
|
held: round({ id: '15', status: 'active', holder_id: '7' }),
|
|
38
38
|
waiting: round({ id: '16', status: 'completed', description: `${draft}\n\n${batch([line])}` }),
|
|
39
39
|
fresh_blank: round({ id: '17', updated_at: new Date(NOW - 5 * 60_000).toISOString() }),
|
|
@@ -56,18 +56,44 @@ await test('a blank round and an empty batch are the only two that close', () =>
|
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
await test('a draft left untouched for
|
|
59
|
+
await test('a draft left untouched for a day expires; a younger one stays (task 1004901, one day since task 1004976)', () => {
|
|
60
60
|
const D = 86400000;
|
|
61
61
|
const at = (days) => round({ description: draft, updated_at: new Date(NOW - days * D).toISOString() });
|
|
62
|
-
assert.equal(autoClose.closeReason(at(
|
|
63
|
-
assert.equal(autoClose.closeReason(at(
|
|
62
|
+
assert.equal(autoClose.closeReason(at(0.9), { now: NOW }), null);
|
|
63
|
+
assert.equal(autoClose.closeReason(at(1.1), { now: NOW }), 'draft_expired');
|
|
64
64
|
assert.equal(autoClose.closeReason({ ...at(30), holder_id: '7' }, { now: NOW }), null, 'a held draft is being written, never expired');
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
await test('the grace period is honoured: a round touched within it stays', () => {
|
|
68
|
-
const t = round({ updated_at: new Date(NOW - 59 * 60_000).toISOString() });
|
|
68
|
+
const t = round({ description: `${draft}\n\n${batch([])}`, updated_at: new Date(NOW - 59 * 60_000).toISOString() });
|
|
69
69
|
assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 60 * 60_000 }), null);
|
|
70
|
-
assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 30 * 60_000 }), '
|
|
70
|
+
assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 30 * 60_000 }), 'nothing_to_apply');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// task 1004972 (owner, task 1004971): a blank round closes after one day, not an hour.
|
|
74
|
+
await test('a blank round closes once a day old, and a round with a draft or a batch never closes as blank', () => {
|
|
75
|
+
const D = 86400000;
|
|
76
|
+
const at = (days, over = {}) => round({ updated_at: new Date(NOW - days * D).toISOString(), ...over });
|
|
77
|
+
assert.equal(autoClose.closeReason(at(0.9), { now: NOW }), null, 'under a day: it stays, reading "Opened, nothing written yet"');
|
|
78
|
+
assert.equal(autoClose.closeReason(at(1.1), { now: NOW }), 'blank');
|
|
79
|
+
assert.equal(autoClose.closeReason(at(1.1, { holder_id: '7' }), { now: NOW }), null, 'a held round is being written');
|
|
80
|
+
for (const [name, description] of [['draft', draft], ['batch with lines', `${draft}\n\n${batch([line])}`],
|
|
81
|
+
['approved batch', `${batch([line])}\n\n${pages.composeWordsApprovedBlock({ at: OLD, by: '7' })}`]]) {
|
|
82
|
+
assert.notEqual(autoClose.closeReason(at(1.1, { description }), { now: NOW }), 'blank', `${name} is not blank`);
|
|
83
|
+
}
|
|
84
|
+
assert.equal(autoClose.closeReason(at(30, { description: `${draft}\n\n${batch([line])}` }), { now: NOW }), null, 'a batch with lines is never closed, however old');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await test('the blank note says nothing was written, and the sweep never writes words', async () => {
|
|
88
|
+
const calls = [];
|
|
89
|
+
const lifecycle = {
|
|
90
|
+
listPageTweakTasks: async () => [ROUNDS.blank],
|
|
91
|
+
releaseStuckTweak: async (args) => { calls.push(args); return { outcome: 'abandoned' }; },
|
|
92
|
+
};
|
|
93
|
+
await autoClose.runAutoClose({ now: NOW, resolve: () => lifecycle, founder: async () => ({ id: '1' }) });
|
|
94
|
+
assert.equal(calls.length, 1);
|
|
95
|
+
assert.match(calls[0].note, /^Closed: nothing was written\./);
|
|
96
|
+
assert.deepEqual(Object.keys(calls[0]).sort(), ['builderId', 'expectedStatus', 'note', 'taskId'], 'no compose, no description, no wording');
|
|
71
97
|
});
|
|
72
98
|
|
|
73
99
|
await test('the sweep abandons through the lifecycle port, as the owner, with a plain note and no rewrite', async () => {
|
|
@@ -86,6 +112,42 @@ await test('the sweep abandons through the lifecycle port, as the owner, with a
|
|
|
86
112
|
}
|
|
87
113
|
});
|
|
88
114
|
|
|
115
|
+
// task 1004973: an apply that stopped without finishing is released with a reason.
|
|
116
|
+
await test('an orphaned apply (active, nobody holds it) is released back to the queue with the reason; a live apply is not', async () => {
|
|
117
|
+
const approved = `${draft}\n\n${batch([line])}\n\n${pages.composeWordsApprovedBlock({ at: OLD, by: '7' })}`;
|
|
118
|
+
const orphan = round({ id: '21', status: 'active', holder_id: null, description: approved });
|
|
119
|
+
const orphanDraft = round({ id: '22', status: 'active', holder_id: null, description: draft });
|
|
120
|
+
const live = round({ id: '23', status: 'active', holder_id: '7', holder_worktree: true, description: approved });
|
|
121
|
+
const fresh = round({ id: '24', status: 'active', holder_id: null, description: approved, updated_at: new Date(NOW - 5 * 60_000).toISOString() });
|
|
122
|
+
assert.deepEqual([orphan, orphanDraft, live, fresh].map((t) => autoClose.orphanReason(t, { now: NOW })), ['orphaned_apply', 'orphaned_apply', null, null]);
|
|
123
|
+
assert.equal(autoClose.closeReason(orphan, { now: NOW }), null, 'the ready-round rules never see an active round');
|
|
124
|
+
|
|
125
|
+
const calls = [];
|
|
126
|
+
const lifecycle = {
|
|
127
|
+
listPageTweakTasks: async () => [orphan, orphanDraft, live, fresh, ROUNDS.real_batch],
|
|
128
|
+
releaseStuckTweak: async (args) => { calls.push(args); return { outcome: 'released' }; },
|
|
129
|
+
};
|
|
130
|
+
const r = await autoClose.runAutoClose({ now: NOW, resolve: () => lifecycle, founder: async () => ({ id: '1' }) });
|
|
131
|
+
assert.deepEqual(r.closed.map((c) => [c.task_id, c.why, c.outcome]), [['21', 'orphaned_apply', 'released'], ['22', 'orphaned_apply', 'released']],
|
|
132
|
+
'after one sweep no round sits active with nobody holding it; the live apply and the fresh one are left alone');
|
|
133
|
+
for (const c of calls) {
|
|
134
|
+
assert.equal(c.expectedStatus, 'active');
|
|
135
|
+
assert.match(c.note, /^Released: the apply stopped without finishing\./);
|
|
136
|
+
assert.equal(typeof c.compose, 'function', 'released back to the queue, not abandoned');
|
|
137
|
+
}
|
|
138
|
+
// The reason is on the round, and the words are kept whole: the compose only appends.
|
|
139
|
+
const [batchOut, draftOut] = calls.map((c, i) => c.compose([orphan, orphanDraft][i]));
|
|
140
|
+
assert.equal(batchOut.ok, true);
|
|
141
|
+
assert.ok(batchOut.description.startsWith(approved), 'the draft, batch and approval are unchanged');
|
|
142
|
+
assert.ok(draftOut.description.startsWith(draft), 'a draft stays as it was');
|
|
143
|
+
const p = pages.blockPresence(batchOut.description);
|
|
144
|
+
assert.equal(p.batch && p.words_approved && !!p.last_sent_back_at, true, 'the batch carries the sent-back reason');
|
|
145
|
+
const pageStatus = require('../page-status.js');
|
|
146
|
+
assert.equal(pageStatus.roundState({ status: 'ready', holder_id: null, description: batchOut.description }), 'submitted', 'back in the /tweak queue');
|
|
147
|
+
assert.equal(pageStatus.roundState({ status: 'ready', holder_id: null, description: draftOut.description }), 'draft', 'a draft is its artist\'s again');
|
|
148
|
+
assert.match(draftOut.description, /the apply stopped without finishing/);
|
|
149
|
+
});
|
|
150
|
+
|
|
89
151
|
await test('nothing due, no owner read; no owner, no sweep; no lifecycle, no sweep', async () => {
|
|
90
152
|
let asked = 0;
|
|
91
153
|
const quiet = { listPageTweakTasks: async () => [ROUNDS.real_batch], releaseStuckTweak: async () => { throw new Error('must not run'); } };
|
|
@@ -199,6 +199,8 @@ await test('every row of the D7 state table', () => {
|
|
|
199
199
|
${pages.composeWordsApprovedBlock({ at: 't2', by: '7' })}` }, 'submitted'],
|
|
200
200
|
[{ status: 'ready', description: sentBackAfterApply }, 'submitted'],
|
|
201
201
|
[{ status: 'ready', description: applied }, 'unknown'],
|
|
202
|
+
// task 1004972: opened and nothing written is its own state, never "Needs to be reviewed".
|
|
203
|
+
[{ status: 'ready', description: 'Page tweak round 1.' }, 'blank'],
|
|
202
204
|
[{ status: 'completed', grade_passed: true }, 'waiting_for_artist'],
|
|
203
205
|
[{ status: 'completed', grade_passed: false }, 'applying'],
|
|
204
206
|
[{ status: 'confirmed' }, 'landing'],
|
|
@@ -258,7 +260,9 @@ ${pages.composeWordsApprovedBlock({ at: ago(9), by: '7' })}`;
|
|
|
258
260
|
round(3, 'landing:c', { status: 'confirmed', updated_at: ago(17) }),
|
|
259
261
|
round(4, 'builders:d', { status: 'completed', grade_passed: true, updated_at: ago(20) }),
|
|
260
262
|
round(5, 'builders:e', { status: 'active', holder_id: '7', holder_login: 'me', holder_worktree: false, updated_at: ago(5) }),
|
|
261
|
-
round
|
|
263
|
+
// An active round nobody holds is one the reads cannot name. (A blank ready
|
|
264
|
+
// round is no longer that: it reads 'blank', task 1004972.)
|
|
265
|
+
round(6, 'builders:f', { status: 'active', holder_id: null, updated_at: ago(0.2) }),
|
|
262
266
|
round(7, 'builders:g', { status: 'shipped', shipped_at: ago(3), updated_at: ago(3) }),
|
|
263
267
|
round(8, 'status:h', { status: 'active', holder_id: '8', holder_worktree: true, updated_at: ago(2) }),
|
|
264
268
|
];
|
|
@@ -319,7 +319,8 @@
|
|
|
319
319
|
notice('<p>The page changed after your draft was saved, so the draft is not shown over its new words. Rewrite the lines again here.</p>');
|
|
320
320
|
}
|
|
321
321
|
if (st.writable) { if (!st.ed.draft_on_older_reading) notice(''); return; }
|
|
322
|
-
|
|
322
|
+
// A blank round (opened, nothing written; task 1004972) is free to take, like a draft.
|
|
323
|
+
if (!r || r.state === 'draft' || r.state === 'blank' || (r.state === 'writing' && !r.holder)) {
|
|
323
324
|
notice('<p>Nobody is writing this page. Take it, and every line here is yours to type over.</p>'
|
|
324
325
|
+ '<button class="tw-notice__act" id="tw-claim" type="button">take this page</button>');
|
|
325
326
|
return;
|
|
@@ -39,7 +39,7 @@ const { unsatisfiedDeps } = require('./db-deps-criteria.js');
|
|
|
39
39
|
const { depRefusalRow } = require('./dead-deps.js');
|
|
40
40
|
const { siblingOverlaps, RESOLVED_STATUSES } = require('./goal-conflicts.js');
|
|
41
41
|
const { claimGate, claimPermissionsFor, disambiguateWorktreeName, foldWorktreeName } = require('./db-rank-authz.js');
|
|
42
|
-
const { sealSessionLog } = require('./db-shared.js');
|
|
42
|
+
const { idKey, sealSessionLog } = require('./db-shared.js');
|
|
43
43
|
const { gatingRebaseTasksForBuilder, shouldRefuseClaimResolve } = require('./db-ship.js');
|
|
44
44
|
const { splitRebaseGate, rebaseRefusalTasks } = require('./rebase-gate.js');
|
|
45
45
|
const { REWARD_SUGGESTION_CAP, rewardGateAssignment } = require('./db-tasks.js');
|
|
@@ -898,7 +898,7 @@ async function releaseClaimsBatch({ claimIds, builderId, resolverSessionId, reso
|
|
|
898
898
|
for (const claimId of uniqIds) {
|
|
899
899
|
const claim = byId.get(claimId);
|
|
900
900
|
if (!claim) { failures.push({ claim_id: claimId, reason: 'CLAIM_NOT_FOUND' }); continue; }
|
|
901
|
-
if (claim.builder_id !== builderId) { failures.push({ claim_id: claimId, reason: 'NOT_YOUR_CLAIM' }); continue; }
|
|
901
|
+
if (idKey(claim.builder_id) !== idKey(builderId)) { failures.push({ claim_id: claimId, reason: 'NOT_YOUR_CLAIM' }); continue; }
|
|
902
902
|
if (claim.released_at) { failures.push({ claim_id: claimId, reason: 'ALREADY_RESOLVED' }); continue; }
|
|
903
903
|
// task 1003767: the SAME decision resolveClaim uses, not a third copy of
|
|
904
904
|
// the auth-session compare (this was the second of three). Batch release
|
|
@@ -16,7 +16,7 @@ const log = api.logger('lifecycle');
|
|
|
16
16
|
const seams = api;
|
|
17
17
|
const doneWhen = require('./done-when.js');
|
|
18
18
|
const { THETES_GRADUATION_THRESHOLD, maybeAutoGraduateToThetes, foldWorktreeName } = require('./db-rank-authz.js');
|
|
19
|
-
const { mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = require('./db-shared.js');
|
|
19
|
+
const { idKey, mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = require('./db-shared.js');
|
|
20
20
|
const { LIVE_STATUSES } = require('./dead-deps.js');
|
|
21
21
|
const { isPageTweak } = require('./page-tweak-hold.js');
|
|
22
22
|
const pageTweakArtist = require('./page-tweak-artist.js');
|
|
@@ -138,7 +138,7 @@ async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary
|
|
|
138
138
|
);
|
|
139
139
|
if (claimRows.length === 0) throw { code: 'CLAIM_NOT_FOUND' };
|
|
140
140
|
const claim = claimRows[0];
|
|
141
|
-
if (claim.builder_id !== builderId) throw { code: 'NOT_YOUR_CLAIM' };
|
|
141
|
+
if (idKey(claim.builder_id) !== idKey(builderId)) throw { code: 'NOT_YOUR_CLAIM' }; // task 1005004: pg bigint string vs a number
|
|
142
142
|
if (claim.released_at) throw { code: 'ALREADY_RESOLVED' };
|
|
143
143
|
// Resolve lock: only the WORKING TREE that created this claim can resolve it
|
|
144
144
|
// (task 1003767) — falling back to the creating session where no tree is
|
|
@@ -443,7 +443,13 @@ module.exports = function buildClaimsRouter() {
|
|
|
443
443
|
// rank: any-builder — atomic multi-claim; same Xenos row-level filter applies.
|
|
444
444
|
// V3.R13 (#237): same newcomer gate as POST /claims — a fresh builder can't
|
|
445
445
|
// sneak around the "one claim at a time" rule by batching.
|
|
446
|
-
|
|
446
|
+
// task 1005005: and the same gateOneActiveClaimPerSession as POST /claims, in
|
|
447
|
+
// the same order, so a session already holding a claim in this tree cannot
|
|
448
|
+
// batch more work into it. It reads the SESSION's existing active claims (the
|
|
449
|
+
// batch's own rows do not exist yet), keyed on the body's worktree_name — the
|
|
450
|
+
// caller's tree, which a batch also uses as its row-name prefix. A
|
|
451
|
+
// bind_session:false batch (the hall) has no session and passes, as on /claims.
|
|
452
|
+
router.post('/claims/batch', auth.requireBuilder, gateOneActiveClaimPerSession, gateNewcomerClaim, async (req, res) => {
|
|
447
453
|
if (validateOrRespond(req, res, {
|
|
448
454
|
task_ids: { required: true, type: 'array', maxItems: 20 },
|
|
449
455
|
worktree_name: { type: 'string', maxLength: LIMITS.WORKTREE_NAME },
|
|
@@ -192,6 +192,131 @@ async function listLoginsForPerson(personKey, { pool = defaultPool } = {}) {
|
|
|
192
192
|
return rows;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
// ── Linking (task 1004995) ──────────────────────────────────────────────────
|
|
196
|
+
// A signed-in person adds a second way in by signing in with it. Never a merge:
|
|
197
|
+
// a way in that already reaches ANOTHER person is refused with 'taken', because
|
|
198
|
+
// both accounts may hold credits, ranks and history and joining them is a
|
|
199
|
+
// decision the server must not make on anyone's behalf (owner, 2026-10-29).
|
|
200
|
+
|
|
201
|
+
// Who signs in with this GitHub user id? → the person key, or null when no one
|
|
202
|
+
// does yet. Every GitHub sign-in on the hub asks this first, so a GitHub account
|
|
203
|
+
// linked to someone who joined with Google reaches THEM, not a second person
|
|
204
|
+
// keyed on the GitHub id (the ADR 0393 carry-over).
|
|
205
|
+
async function personKeyForGithub(githubId, { pool = defaultPool } = {}) {
|
|
206
|
+
const person = await findPersonByLogin({ provider: 'github', subject: githubId }, { pool });
|
|
207
|
+
return person ? person.personKey : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Add a way in to the signed-in person → { linked: true, already } or
|
|
211
|
+
// { refused: 'taken' | 'already_linked' | 'no_account' }. For GitHub the linked
|
|
212
|
+
// login is also written on the account (ADR 0393 §4: NULL until linking sets it).
|
|
213
|
+
async function linkLogin({ personKey, provider, subject, email, githubLogin } = {}, { pool = defaultPool } = {}) {
|
|
214
|
+
const l = normalizeLogin({ provider, subject });
|
|
215
|
+
if (!l || !pi.isNumericGithubId(personKey)) throw new Error('linkLogin: not a valid person or provider identity');
|
|
216
|
+
const owner = await findPersonByLogin(l, { pool });
|
|
217
|
+
if (owner) return owner.personKey === String(personKey) ? { linked: true, already: true } : { refused: 'taken' };
|
|
218
|
+
try {
|
|
219
|
+
return await withTx(async (client) => {
|
|
220
|
+
const { rows } = await client.query(
|
|
221
|
+
`SELECT id FROM platform_identity_accounts WHERE github_id = $1`, [String(personKey)],
|
|
222
|
+
);
|
|
223
|
+
if (!rows[0]) return { refused: 'no_account' };
|
|
224
|
+
await client.query(
|
|
225
|
+
`INSERT INTO platform_identity_account_logins (account_id, provider, subject, email, last_used_at)
|
|
226
|
+
VALUES ($1, $2, $3, $4, now())`,
|
|
227
|
+
[rows[0].id, l.provider, l.subject, cleanEmail(email)],
|
|
228
|
+
);
|
|
229
|
+
if (l.provider === 'github') {
|
|
230
|
+
await client.query(
|
|
231
|
+
`UPDATE platform_identity_accounts SET github_login = $2 WHERE id = $1`,
|
|
232
|
+
[rows[0].id, typeof githubLogin === 'string' && githubLogin ? githubLogin.slice(0, 100) : null],
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return { linked: true, already: false };
|
|
236
|
+
}, { pool });
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (err && err.code === '23505') {
|
|
239
|
+
// Which UNIQUE fired decides the answer: another person took this way in
|
|
240
|
+
// a moment ago, or this person already holds one for the provider.
|
|
241
|
+
const raced = await findPersonByLogin(l, { pool });
|
|
242
|
+
if (raced && raced.personKey !== String(personKey)) return { refused: 'taken' };
|
|
243
|
+
if (raced) return { linked: true, already: true };
|
|
244
|
+
return { refused: 'already_linked' };
|
|
245
|
+
}
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Why this way in cannot be removed, or null when it can. Never the last way in
|
|
251
|
+
// (owner, 2026-10-29); and never GitHub from an account born with it, because
|
|
252
|
+
// that account's key IS its GitHub id, so a GitHub sign-in would still reach it.
|
|
253
|
+
function unlinkRefusal({ personKey, provider, count }) {
|
|
254
|
+
if (provider === 'github' && !isMintedPersonKey(personKey)) return 'born_with';
|
|
255
|
+
if (count <= 1) return 'last_way_in';
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Remove a way in from the signed-in person → { unlinked: true } or
|
|
260
|
+
// { refused: 'not_linked' | 'last_way_in' | 'born_with' }. The count and the
|
|
261
|
+
// delete share one transaction, with the rows locked, so two racing removals
|
|
262
|
+
// cannot both pass the last-way-in check.
|
|
263
|
+
async function unlinkLogin({ personKey, provider } = {}, { pool = defaultPool } = {}) {
|
|
264
|
+
if (!LOGIN_PROVIDERS.includes(provider) || !pi.isNumericGithubId(personKey)) return { refused: 'not_linked' };
|
|
265
|
+
return withTx(async (client) => {
|
|
266
|
+
const { rows } = await client.query(
|
|
267
|
+
`SELECT l.id, l.provider, a.id AS account_id
|
|
268
|
+
FROM platform_identity_account_logins l
|
|
269
|
+
JOIN platform_identity_accounts a ON a.id = l.account_id
|
|
270
|
+
WHERE a.github_id = $1
|
|
271
|
+
FOR UPDATE OF l`,
|
|
272
|
+
[String(personKey)],
|
|
273
|
+
);
|
|
274
|
+
const row = rows.find((r) => r.provider === provider);
|
|
275
|
+
if (!row) return { refused: 'not_linked' };
|
|
276
|
+
const why = unlinkRefusal({ personKey: String(personKey), provider, count: rows.length });
|
|
277
|
+
if (why) return { refused: why };
|
|
278
|
+
await client.query(`DELETE FROM platform_identity_account_logins WHERE id = $1`, [row.id]);
|
|
279
|
+
if (provider === 'github') {
|
|
280
|
+
await client.query(`UPDATE platform_identity_accounts SET github_login = NULL WHERE id = $1`, [row.account_id]);
|
|
281
|
+
}
|
|
282
|
+
return { unlinked: true };
|
|
283
|
+
}, { pool });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// A first sign-in whose address another way in already carries → the providers
|
|
287
|
+
// to suggest instead, e.g. ['github'], or [] (task 1004995). Asked only while
|
|
288
|
+
// this provider identity reaches no one yet, so the person can sign in with the
|
|
289
|
+
// account they already have and link this one there, rather than make a second
|
|
290
|
+
// account they would never be able to join to the first. Only provider NAMES
|
|
291
|
+
// come back — never who, never the address — and nothing is joined: owning a
|
|
292
|
+
// matching address must not reach anyone's account (owner, 2026-10-29). This is
|
|
293
|
+
// the one read that compares emails.
|
|
294
|
+
async function existingAccountHint({ provider, subject, email } = {}, { pool = defaultPool } = {}) {
|
|
295
|
+
const l = normalizeLogin({ provider, subject });
|
|
296
|
+
const e = cleanEmail(email);
|
|
297
|
+
if (!l || !e) return [];
|
|
298
|
+
if (await findPersonByLogin(l, { pool })) return [];
|
|
299
|
+
const { rows } = await pool.query(
|
|
300
|
+
`SELECT DISTINCT provider FROM platform_identity_account_logins
|
|
301
|
+
WHERE provider <> $1 AND lower(email) = lower($2)`,
|
|
302
|
+
[l.provider, e],
|
|
303
|
+
);
|
|
304
|
+
return LOGIN_PROVIDERS.filter((p) => rows.some((r) => r.provider === p));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The hub's own GitHub sign-in stamps its way in as used, and keeps the address
|
|
308
|
+
// GitHub showed (its public one), so a later Google sign-in can be offered a
|
|
309
|
+
// link. Display and suggestion only, like every email here.
|
|
310
|
+
async function touchGithubLogin({ githubId, email } = {}, { pool = defaultPool } = {}) {
|
|
311
|
+
const l = normalizeLogin({ provider: 'github', subject: githubId });
|
|
312
|
+
if (!l) return;
|
|
313
|
+
await pool.query(
|
|
314
|
+
`UPDATE platform_identity_account_logins SET last_used_at = now(), email = COALESCE($2, email)
|
|
315
|
+
WHERE provider = 'github' AND subject = $1`,
|
|
316
|
+
[l.subject, cleanEmail(email)],
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
195
320
|
module.exports = {
|
|
196
321
|
LOGIN_PROVIDERS,
|
|
197
322
|
normalizeLogin,
|
|
@@ -202,4 +327,10 @@ module.exports = {
|
|
|
202
327
|
createAccountForLogin,
|
|
203
328
|
signInWithLogin,
|
|
204
329
|
listLoginsForPerson,
|
|
330
|
+
personKeyForGithub,
|
|
331
|
+
linkLogin,
|
|
332
|
+
unlinkRefusal,
|
|
333
|
+
unlinkLogin,
|
|
334
|
+
existingAccountHint,
|
|
335
|
+
touchGithubLogin,
|
|
205
336
|
};
|
package/modules/platform-identity/migrations/platform_identity_035_account_login_email_idx.sql
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- platform_identity_035 — index the ways in by address, for the one email read
|
|
2
|
+
-- (task 1004995, ADR 0396 amendment "linking").
|
|
3
|
+
--
|
|
4
|
+
-- existingAccountHint (account-logins.js) asks, on a first Google sign-in, whether
|
|
5
|
+
-- another way in shows the address Google verified, so the person can be offered
|
|
6
|
+
-- the account they already have instead of a second one. It compares
|
|
7
|
+
-- lower(email) = lower($2), which no index served: every first Google sign-in
|
|
8
|
+
-- would scan the whole table. This partial expression index serves exactly that
|
|
9
|
+
-- read and skips the rows with no address (most GitHub ways in).
|
|
10
|
+
--
|
|
11
|
+
-- Additive and idempotent: an index only, no column or constraint changes, so a
|
|
12
|
+
-- reader that never applies this file still works, only slower.
|
|
13
|
+
|
|
14
|
+
CREATE INDEX IF NOT EXISTS platform_identity_account_logins_lower_email_idx
|
|
15
|
+
ON platform_identity_account_logins (lower(email))
|
|
16
|
+
WHERE email IS NOT NULL;
|
|
17
|
+
|
|
18
|
+
COMMENT ON INDEX platform_identity_account_logins_lower_email_idx IS
|
|
19
|
+
'Serves existingAccountHint''s lower(email) match on a first Google sign-in (task 1004995). A suggestion read only: no sign-in or link is decided by an email.';
|
|
@@ -239,9 +239,17 @@ module.exports = function profileRoutes() {
|
|
|
239
239
|
// ADR 0393): GitHub, Google, or both, with the address each provider vouched
|
|
240
240
|
// for. Keyed on the authenticated github_id (the person key); the provider's
|
|
241
241
|
// user id never crosses. A system:* principal has no platform account: [].
|
|
242
|
+
//
|
|
243
|
+
// Each method carries `unlink_refused` (null, 'last_way_in' or 'born_with') so the
|
|
244
|
+
// page can say why a Remove is missing (task 1004995).
|
|
242
245
|
router.get('/me/sign-in-methods', requireBuilder, async (req, res) => {
|
|
243
246
|
try {
|
|
244
|
-
const
|
|
247
|
+
const key = req.builder.github_id;
|
|
248
|
+
const rows = await logins.listLoginsForPerson(key, { pool });
|
|
249
|
+
const methods = rows.map((m) => ({
|
|
250
|
+
...m,
|
|
251
|
+
unlink_refused: logins.unlinkRefusal({ personKey: String(key), provider: m.provider, count: rows.length }),
|
|
252
|
+
}));
|
|
245
253
|
res.set('Cache-Control', 'no-store');
|
|
246
254
|
res.json({ methods, providers: logins.LOGIN_PROVIDERS });
|
|
247
255
|
} catch (err) {
|
|
@@ -250,5 +258,25 @@ module.exports = function profileRoutes() {
|
|
|
250
258
|
}
|
|
251
259
|
});
|
|
252
260
|
|
|
261
|
+
// rank: any-builder — remove one way in from the caller's OWN account (task
|
|
262
|
+
// 1004995), keyed on the authenticated person key. Never the last way in, and
|
|
263
|
+
// never GitHub from an account born with it (its key is its GitHub id). Adding
|
|
264
|
+
// a way in is a sign-in, so it lives with the sign-in routes, not here.
|
|
265
|
+
router.delete('/me/sign-in-methods/:provider', requireBuilder, async (req, res) => {
|
|
266
|
+
const provider = String(req.params.provider || '');
|
|
267
|
+
if (!logins.LOGIN_PROVIDERS.includes(provider)) return res.fail('unknown_provider', 404);
|
|
268
|
+
try {
|
|
269
|
+
const out = await logins.unlinkLogin({ personKey: req.builder.github_id, provider }, { pool });
|
|
270
|
+
res.set('Cache-Control', 'no-store');
|
|
271
|
+
if (out.unlinked) return res.json({ ok: true });
|
|
272
|
+
if (out.refused === 'not_linked') return res.fail('not_linked', { status: 404, message: 'that way in is not linked to your account' });
|
|
273
|
+
if (out.refused === 'last_way_in') return res.fail('last_way_in', { status: 409, message: 'this is your only way in, so it cannot be removed — link another first' });
|
|
274
|
+
return res.fail('born_with', { status: 409, message: 'your account was made with GitHub, so GitHub stays linked' });
|
|
275
|
+
} catch (err) {
|
|
276
|
+
api.logger('platform-identity').error(`DELETE /me/sign-in-methods/:provider failed: ${err && err.message}`);
|
|
277
|
+
res.fail('sign_in_method_unlink_failed', { status: 500, message: 'internal error' });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
253
281
|
return router;
|
|
254
282
|
};
|
|
@@ -32,7 +32,8 @@ const guildEngagements = require('../guild-engagements');
|
|
|
32
32
|
const instanceFeedback = require('../instance-feedback');
|
|
33
33
|
const projectStats = require('../project-stats');
|
|
34
34
|
// Ways in other than GitHub (task 1004993, ADR 0393/0396).
|
|
35
|
-
const
|
|
35
|
+
const logins = require('../account-logins');
|
|
36
|
+
const { signInWithLogin, hallTakesPersonKey } = logins;
|
|
36
37
|
|
|
37
38
|
// What a Google-only person reads when a project's hall runs a core too old to
|
|
38
39
|
// take them (task 1004994).
|
|
@@ -243,8 +244,21 @@ function registerSeams() {
|
|
|
243
244
|
// a human accepts the platform terms, beside /sso/authorize. Core passes the
|
|
244
245
|
// identity GitHub returned for the code it exchanged, never a request field,
|
|
245
246
|
// and `activate: true` is a source literal here exactly as it is there.
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
// `githubId` is the PERSON key the sign-in resolved (task 1004995: a GitHub
|
|
248
|
+
// account linked to someone who joined with Google reaches their key), and
|
|
249
|
+
// `githubUserId` + `email` stamp the GitHub way in itself as used.
|
|
250
|
+
recordHubSignin: async ({ githubId, githubLogin, displayName, avatarUrl, githubUserId, email }) => {
|
|
251
|
+
await pi.upsertAccount({ githubId, githubLogin, displayName, avatarUrl, activate: true }, { pool });
|
|
252
|
+
await logins.touchGithubLogin({ githubId: githubUserId || githubId, email }, { pool });
|
|
253
|
+
},
|
|
254
|
+
// task 1004995: who a GitHub sign-in reaches (null = no one yet, so the
|
|
255
|
+
// GitHub id itself), and linking a second way in to the signed-in person.
|
|
256
|
+
personKeyForGithub: (githubUserId) => logins.personKeyForGithub(githubUserId, { pool }),
|
|
257
|
+
// A first Google sign-in whose verified address another way in carries →
|
|
258
|
+
// the providers to offer instead (names only; nothing is joined).
|
|
259
|
+
googleSignInHint: ({ subject, email }) => logins.existingAccountHint({ provider: 'google', subject, email }, { pool }),
|
|
260
|
+
linkLogin: ({ personKey, provider, subject, email, githubLogin }) => logins.linkLogin({
|
|
261
|
+
personKey, provider, subject, email, githubLogin,
|
|
248
262
|
}, { pool }),
|
|
249
263
|
// task 1004993 (ADR 0396): the hub's own Google sign-in. Finds the person by
|
|
250
264
|
// Google's own id or creates them; never by an email. Core passes only what it
|
|
@@ -501,6 +515,14 @@ module.exports = function ssoRoutes() {
|
|
|
501
515
|
if (out.state !== 'ok') return res.json(out); // pending | slow_down | expired | denied
|
|
502
516
|
if (!out.client_id) return res.fail('device_poll_failed', { status: 500, message: 'internal error' });
|
|
503
517
|
const id = out.identity;
|
|
518
|
+
// A GitHub account linked to someone who joined with Google is THAT person
|
|
519
|
+
// (task 1004995). This leg carries no sign of whether the project's core can
|
|
520
|
+
// take their key, so it never mints one for them: it says to sign in to the
|
|
521
|
+
// project in the browser, where /sso/authorize can tell.
|
|
522
|
+
const personKey = await logins.personKeyForGithub(id.github_id);
|
|
523
|
+
if (personKey && personKey !== String(id.github_id)) {
|
|
524
|
+
return res.fail('sign_in_in_browser', { status: 409, message: 'This GitHub account is linked to an account that joined with Google. Sign in to the project in your browser instead.' });
|
|
525
|
+
}
|
|
504
526
|
await pi.upsertAccount({
|
|
505
527
|
githubId: id.github_id,
|
|
506
528
|
githubLogin: id.github_login,
|
|
@@ -214,6 +214,22 @@
|
|
|
214
214
|
</form>
|
|
215
215
|
</section>
|
|
216
216
|
|
|
217
|
+
<!-- WAYS TO SIGN IN — GET/DELETE /me/sign-in-methods (task 1004995). Linking is
|
|
218
|
+
a sign-in with the other account, so Link is a link to the sign-in routes,
|
|
219
|
+
and their answer comes back here as ?linked= or ?link_refused=. A way in
|
|
220
|
+
the server says cannot be removed shows why instead of a Remove. The rows
|
|
221
|
+
are cosmos.css's .mineRow, as under "projects you build on". -->
|
|
222
|
+
<section class="aSec" id="aWaysIn" aria-labelledby="aWaysIn-h">
|
|
223
|
+
<h2 id="aWaysIn-h">ways to sign in</h2>
|
|
224
|
+
<p class="aSide">GitHub, Google or both. Each one opens this same account, with the same credits, ranks and history.</p>
|
|
225
|
+
<div class="aBody">
|
|
226
|
+
<p class="aWhat" id="waysLoading">Reading your ways to sign in…</p>
|
|
227
|
+
<ul class="mineRows" id="waysList"></ul>
|
|
228
|
+
<p class="finePrint">Linking never joins two accounts. If the other one already has its own account here, we say so and change nothing.</p>
|
|
229
|
+
<p class="projMsg" id="waysMsg" role="status" aria-live="polite"></p>
|
|
230
|
+
</div>
|
|
231
|
+
</section>
|
|
232
|
+
|
|
217
233
|
<!-- WHO CAN FIND YOU — account_visibility + recruiter_discoverable (privacy
|
|
218
234
|
spec D1/D3, task 1002966), PATCH /me/cross-project. Ported from the hall's
|
|
219
235
|
old "Profile privacy" section. The recruiter copy must name EXACTLY what
|
|
@@ -378,6 +394,7 @@
|
|
|
378
394
|
}
|
|
379
395
|
loadBuilding();
|
|
380
396
|
loadGuilds();
|
|
397
|
+
loadWaysIn();
|
|
381
398
|
}, function () { showAlert('Couldn’t reach the server — check your connection and refresh.'); });
|
|
382
399
|
}
|
|
383
400
|
|
|
@@ -697,6 +714,103 @@
|
|
|
697
714
|
}, function () { restore(); say(msg, 'Couldn’t reach the server. Try again.', 'err'); });
|
|
698
715
|
}
|
|
699
716
|
|
|
717
|
+
// ---- ways to sign in (task 1004995). Read once the account is shown. A
|
|
718
|
+
// link's answer arrives in the address (?linked= / ?link_refused=) from the
|
|
719
|
+
// sign-in routes, and is said here in the page's own words. ----
|
|
720
|
+
var PROVIDER_NAME = { github: 'GitHub', google: 'Google' };
|
|
721
|
+
var LINK_START = { github: '/auth/link/github/start', google: '/auth/google/start?link=1' };
|
|
722
|
+
var KEPT_WHY = {
|
|
723
|
+
last_way_in: 'Your only way in, so it stays. Link another to be able to remove it.',
|
|
724
|
+
born_with: 'Your account was made with GitHub, so GitHub stays linked.',
|
|
725
|
+
};
|
|
726
|
+
function linkAnswer() {
|
|
727
|
+
var q = window.location.search || '';
|
|
728
|
+
var linked = /[?&]linked=(github|google)(&|$)/.exec(q);
|
|
729
|
+
if (linked) return { text: PROVIDER_NAME[linked[1]] + ' is linked. You can now sign in with either.', kind: 'ok' };
|
|
730
|
+
var refused = /[?&]link_refused=([a-z_]+)/.exec(q);
|
|
731
|
+
if (!refused) return null;
|
|
732
|
+
var p = /[?&]provider=(github|google)(&|$)/.exec(q);
|
|
733
|
+
var name = p ? PROVIDER_NAME[p[1]] : 'That';
|
|
734
|
+
var words = {
|
|
735
|
+
taken: 'That ' + name + ' account already has its own account here, so it was not linked. We never join two accounts; nothing changed.',
|
|
736
|
+
already_linked: 'Your account already has a ' + name + ' account linked. Remove it first to link a different one.',
|
|
737
|
+
cancelled: 'You cancelled linking ' + name + '. Nothing changed.',
|
|
738
|
+
expired: 'Linking ' + name + ' took too long. Try again.',
|
|
739
|
+
};
|
|
740
|
+
return { text: words[refused[1]] || 'Couldn’t link ' + name + '. Try again.', kind: 'err' };
|
|
741
|
+
}
|
|
742
|
+
function loadWaysIn() {
|
|
743
|
+
var msg = $('waysMsg');
|
|
744
|
+
var answer = linkAnswer();
|
|
745
|
+
if (answer) say(msg, answer.text, answer.kind);
|
|
746
|
+
return request('GET', '/me/sign-in-methods').then(function (res) {
|
|
747
|
+
$('waysLoading').hidden = true;
|
|
748
|
+
if (!res.ok || !res.data) { say(msg, 'Couldn’t read your ways to sign in — refresh in a moment.', 'err'); return; }
|
|
749
|
+
paintWaysIn(res.data.methods || [], res.data.providers || []);
|
|
750
|
+
}, function () {
|
|
751
|
+
$('waysLoading').hidden = true;
|
|
752
|
+
say(msg, 'Couldn’t reach the server — check your connection and refresh.', 'err');
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
function paintWaysIn(methods, providers) {
|
|
756
|
+
var ul = $('waysList');
|
|
757
|
+
ul.textContent = '';
|
|
758
|
+
providers.forEach(function (p) {
|
|
759
|
+
var held = methods.filter(function (m) { return m.provider === p; })[0];
|
|
760
|
+
ul.appendChild(wayRow(p, held));
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
function wayRow(provider, held) {
|
|
764
|
+
var name = PROVIDER_NAME[provider] || provider;
|
|
765
|
+
var row = rowEl('li', 'mineRow');
|
|
766
|
+
var main = rowEl('div', 'mineRow__main');
|
|
767
|
+
var title = rowEl('div', 'mineRow__name');
|
|
768
|
+
title.textContent = name;
|
|
769
|
+
main.appendChild(title);
|
|
770
|
+
var line = rowEl('span', 'mineRow__host');
|
|
771
|
+
line.textContent = held ? (held.email || 'linked') : 'not linked';
|
|
772
|
+
main.appendChild(line);
|
|
773
|
+
if (held && held.unlink_refused && KEPT_WHY[held.unlink_refused]) {
|
|
774
|
+
var why = rowEl('span', 'aTag');
|
|
775
|
+
why.textContent = KEPT_WHY[held.unlink_refused];
|
|
776
|
+
main.appendChild(why);
|
|
777
|
+
}
|
|
778
|
+
row.appendChild(main);
|
|
779
|
+
var actions = rowEl('div', 'mineRow__actions');
|
|
780
|
+
if (!held) {
|
|
781
|
+
var a = document.createElement('a');
|
|
782
|
+
a.className = 'btn small';
|
|
783
|
+
a.href = API + LINK_START[provider];
|
|
784
|
+
a.textContent = 'Link ' + name;
|
|
785
|
+
actions.appendChild(a);
|
|
786
|
+
} else if (!held.unlink_refused) {
|
|
787
|
+
var btn = document.createElement('button');
|
|
788
|
+
btn.type = 'button';
|
|
789
|
+
btn.className = 'btn small ghost';
|
|
790
|
+
btn.textContent = 'Remove';
|
|
791
|
+
btn.setAttribute('aria-label', 'Remove ' + name);
|
|
792
|
+
btn.addEventListener('click', function () { unlinkWay(provider, name, btn); });
|
|
793
|
+
actions.appendChild(btn);
|
|
794
|
+
}
|
|
795
|
+
row.appendChild(actions);
|
|
796
|
+
return row;
|
|
797
|
+
}
|
|
798
|
+
function unlinkWay(provider, name, btn) {
|
|
799
|
+
var msg = $('waysMsg');
|
|
800
|
+
btn.disabled = true;
|
|
801
|
+
btn.textContent = 'Removing…';
|
|
802
|
+
request('DELETE', '/me/sign-in-methods/' + provider).then(function (res) {
|
|
803
|
+
if (res.status === 401) { btn.disabled = false; btn.textContent = 'Remove'; sessionGone(msg); return; }
|
|
804
|
+
if (!res.ok) {
|
|
805
|
+
btn.disabled = false; btn.textContent = 'Remove';
|
|
806
|
+
say(msg, serverWords(res, 'Couldn’t remove ' + name + '. Try again.'), 'err');
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
say(msg, name + ' is removed. It no longer opens this account.', 'ok');
|
|
810
|
+
loadWaysIn();
|
|
811
|
+
}, function () { btn.disabled = false; btn.textContent = 'Remove'; say(msg, 'Couldn’t reach the server. Try again.', 'err'); });
|
|
812
|
+
}
|
|
813
|
+
|
|
700
814
|
// The identity read failing is not a sign-out. If the account itself was
|
|
701
815
|
// read, the chip names it from that answer, so the way out is never lost.
|
|
702
816
|
load().then(function () { return bar.ready; }).then(function () {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"in-projects-band": { "auth": true, "actions": [["wait", 400], ["click", "#projTrig"], ["wait", 300]], "expect": { "visible": ["#menuProjects", "#mineCell"], "hidden": ["#menuProfile"] } },
|
|
14
14
|
"in-profile-band": { "auth": true, "actions": [["wait", 400], ["click", "#who"], ["wait", 300]], "expect": { "visible": ["#menuProfile"], "hidden": ["#menuProjects"], "text": [["#bandWhoLogin", "builder"], ["#menuProfile [aria-current=\"page\"]", "Account"]] } },
|
|
15
15
|
"in-building": { "auth": true, "url": "/account#aBuilding", "actions": [["wait", 500]], "expect": { "visible": ["#aBuilding", "#buildingList"], "hidden": ["#buildingEmpty", "#buildingLoading"], "count": [["#buildingList .mineRow", 2]], "text": [["#buildingList .mineRow:first-child .mineRow__name", "Example World"], ["#buildingList .mineRow:first-child .mineRow__host", "example.com"]], "attr": [["#buildingList .mineRow:first-child button", "aria-label", "Leave Example World"]] } },
|
|
16
|
+
"in-ways": { "auth": true, "url": "/account#aWaysIn", "actions": [["wait", 500]], "expect": { "visible": ["#aWaysIn", "#waysList"], "hidden": ["#waysLoading"], "count": [["#waysList .mineRow", 2]], "text": [["#waysList .mineRow:first-child .mineRow__name", "GitHub"], ["#waysList .mineRow:nth-child(2) .mineRow__host", "octo@example.com"]], "attr": [["#waysList .mineRow:nth-child(2) button", "aria-label", "Remove Google"]] } },
|
|
16
17
|
"in-privacy": { "auth": true, "actions": [["wait", 400], ["click", "#aPrivacy .aOpt:nth-child(2)"], ["wait", 150]], "expect": { "checked": "input[name=visibility][value=private]", "visible": ["#visGuilds"], "hidden": ["#recordGuilds"], "count": [["#visGuildList li", 2]], "attr": [["#visGuildList li:first-child a", "href", "/g/forge-crew"]], "text": [["#visGuildList li:first-child a", "Forge Crew"], ["#recWhat", "They will see your name, your picture, your roles, and whether you have been active in the last 30 days — never your credits, works shipped, karma, or the projects you build on. This is the one thing a recruiter can see while your profile page is private. Turning this off removes you from both recruiting surfaces — the recruiting directory, and a recruiter’s look-up of one builder — straight away. · For now this follows your profile page setting."]] } }
|
|
17
18
|
},
|
|
18
19
|
"reducedMotion": { "auth": true, "actions": [["wait", 300]] }
|
|
@@ -580,6 +580,27 @@ function myGuilds(res) {
|
|
|
580
580
|
}));
|
|
581
581
|
return json(res, 200, { guilds: mine, count: mine.length });
|
|
582
582
|
}
|
|
583
|
+
// GET/DELETE /me/sign-in-methods (the account page's ways to sign in, task 1004995).
|
|
584
|
+
// The fixture builder is GitHub-born with Google linked, held for the run so a
|
|
585
|
+
// Remove reads back. Each way in carries the route's own unlink_refused, by
|
|
586
|
+
// account-logins.js unlinkRefusal's rule, and DELETE refuses as the route does.
|
|
587
|
+
let _ways = null;
|
|
588
|
+
function waysHeld() { return _ways || (_ways = [{ provider: 'github', email: null }, { provider: 'google', email: 'octo@example.com' }]); }
|
|
589
|
+
function wayRefusal(provider, count) { return provider === 'github' ? 'born_with' : count <= 1 ? 'last_way_in' : null; }
|
|
590
|
+
function signInMethods(res) {
|
|
591
|
+
const held = waysHeld();
|
|
592
|
+
return json(res, 200, { methods: held.map((m) => ({ ...m, unlink_refused: wayRefusal(m.provider, held.length) })), providers: ['github', 'google'] });
|
|
593
|
+
}
|
|
594
|
+
function signInMethodDelete(res, provider) {
|
|
595
|
+
const held = waysHeld();
|
|
596
|
+
if (!['github', 'google'].includes(provider)) return refuse(res, 404, 'unknown_provider', 'unknown_provider');
|
|
597
|
+
if (!held.some((m) => m.provider === provider)) return refuse(res, 404, 'not_linked', 'that way in is not linked to your account');
|
|
598
|
+
const why = wayRefusal(provider, held.length);
|
|
599
|
+
if (why === 'born_with') return refuse(res, 409, why, 'your account was made with GitHub, so GitHub stays linked');
|
|
600
|
+
if (why) return refuse(res, 409, why, 'this is your only way in, so it cannot be removed — link another first');
|
|
601
|
+
_ways = held.filter((m) => m.provider !== provider);
|
|
602
|
+
return json(res, 200, { ok: true });
|
|
603
|
+
}
|
|
583
604
|
function myGuildRequests(res) {
|
|
584
605
|
const q = heldFixture('me__guild-requests');
|
|
585
606
|
const owner = ownedRequests();
|
|
@@ -790,6 +811,9 @@ const server = http.createServer((req, res) => {
|
|
|
790
811
|
if (guild && guild[2] === '/membership' && req.method === 'PATCH') return AUTH ? readBody(req, (body) => guildMembershipPatch(res, guild[1], body)) : json(res, 401, UNAUTH);
|
|
791
812
|
if (guild && guild[2] === '/engagements' && req.method === 'POST') return AUTH ? readBody(req, (body) => guildEngagementPost(res, guild[1], body)) : json(res, 401, UNAUTH);
|
|
792
813
|
if (rest === '/me/guilds' && req.method === 'GET') return AUTH ? myGuilds(res) : json(res, 401, UNAUTH);
|
|
814
|
+
if (rest === '/me/sign-in-methods' && req.method === 'GET') return AUTH ? signInMethods(res) : json(res, 401, UNAUTH);
|
|
815
|
+
const way = rest.match(/^\/me\/sign-in-methods\/([^/]+)$/);
|
|
816
|
+
if (way && req.method === 'DELETE') return AUTH ? signInMethodDelete(res, way[1]) : json(res, 401, UNAUTH);
|
|
793
817
|
if (rest === '/me/guild-requests' && req.method === 'GET') return AUTH ? myGuildRequests(res) : json(res, 401, UNAUTH);
|
|
794
818
|
if (rest === '/community/leaderboard' && req.method === 'GET') return communityBoard(res, u, origin);
|
|
795
819
|
if (rest === '/community/search' && req.method === 'GET') return communitySearch(res, u, origin);
|