Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
829144d
rewrite register account context/role
Sep 25, 2026
9a2ebc9
Merge branch 'master' into refactor_register_account
Sep 25, 2026
4198f18
handfixes for ki found problems
Sep 26, 2026
624e22d
same behaviour in migration and new register
Sep 26, 2026
5cc19ad
createUser return only boolean, adapt tests usw. for this.
Sep 26, 2026
c3e82a1
vibde fixed/moved tests
Sep 26, 2026
427ac2f
Merge branch 'master' into refactor_register_account
Sep 26, 2026
987ec2f
Merge branch 'master' into refactor_register_account
Sep 26, 2026
b1d42e7
Merge branch 'master' into refactor_register_account
Sep 26, 2026
02c5e31
move user already exist case into default role, to have correct order…
Sep 28, 2026
7a48e5f
fixes parts of the agent invented rules
Sep 29, 2026
c6940c0
optimize table code, make it independent from referreAlias
Sep 29, 2026
afac8b6
use spezialized schemas per role for createUser, move password verify…
Sep 29, 2026
d68d656
fix some bugs
Sep 29, 2026
2dd8af3
update test for new drizzle usage
Sep 29, 2026
8ee8815
final commit
Sep 29, 2026
699f3ad
Merge branch 'master' into refactor_register_account
Sep 29, 2026
730f648
Merge branch 'master' into refactor_register_account
einhornimmond Sep 29, 2026
71688e3
code rabbit fixes, let return password encryption function at once on…
Sep 30, 2026
a8285df
rename presence (table assisted register) to guarantor
Sep 30, 2026
e4305a5
Merge branch 'refactor_register_account' of github.com:gradido/gradid…
Sep 30, 2026
31416c1
Merge branch 'master' into refactor_register_account
Sep 30, 2026
0c13b55
feat(frontend): duplicate a video call from its invitation
BerndHueckstaedt Sep 30, 2026
aac9ecd
fix some more code rabbit findings
Sep 30, 2026
5fa319b
fix(frontend): set a duplicated room only for its own question, while…
BerndHueckstaedt Sep 30, 2026
5fed353
lint
Sep 30, 2026
6e0d340
Merge pull request #4017 from gradido/refactor_register_account
einhornimmond Sep 30, 2026
9605c84
Merge remote-tracking branch 'origin/master' into chat-video-duplicate
BerndHueckstaedt Sep 30, 2026
4a680f5
Merge pull request #4026 from gradido/chat-video-duplicate
BerndHueckstaedt Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion @types/random-bigint/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@

declare module 'random-bigint' {
function random(bits: number, cb?: (err: Error, num: BigInt) => void): BigInt
function random(bits: number): bigint
function random(bits: number, cb: (err: Error | null, num?: bigint) => void): void
export = random
}
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ Turbo and `bun run` automatically invoke the correct test runner defined in the
- Tests: co-located `*.test.ts`, run with `bun test`.
- Fake timers in Jest tests: use `useFakeTimersForDrizzle()` from `backend/test/helpers.ts` (or `dht-node/test/helpers.ts`) instead of `jest.useFakeTimers()`. Jest 27 also fakes `process.nextTick`, which mysql2 — Drizzle's driver — needs to deliver every result, so any Drizzle query under plain fake timers hangs until the hook or test timeout. TypeORM runs on the `mysql` package and is unaffected, so this only surfaces once a query on that path moves to Drizzle. `federation` has no such helper yet; it needs the same one before a test there fakes timers around a Drizzle query.

# Performance

Always weigh performance and readability together — neither is traded away silently for the other.

- **Database operations are expensive.** Every round trip costs, however cheap the query itself. No queries inside loops: fetch what a loop needs in one query and write its results in batches.
- **Moving large amounts of data into Node.js is expensive too.** Analysing or aggregating large datasets belongs in the database as far as it can go; only the result travels to Node.js.
- **Loading a whole table into memory is the exception**, not the pattern — justified only where the logic cannot run in SQL (migration `0116` builds aliases with a transliteration that exists only in TypeScript).
- **"It only runs once" is no argument.** There are ~150 migrations, each runs on every community server before its services come up, and a fresh setup with data runs all of them. CI runs them against an empty database, so their cost never shows up there — it has to be caught in review.

## Migrations

- Data migrations write in batches of 500 rows wherever possible.
- Import as little as possible. A migration must keep doing exactly what it did when it was written, so it carries a frozen copy of the rules it applies instead of importing helpers that keep evolving (see `0116`). Import only what cannot reasonably be rebuilt in plain TypeScript or SQL — `0102` needs the decay calculation from `shared-native`, `0116` the transliteration tables.

# Error handling

This is not throw-vs-return as a blanket rule — it depends on what kind of failure it is. There are no throw-free zones in this codebase; the question is always which kind of failure you are looking at.
Expand Down Expand Up @@ -172,6 +186,7 @@ Remove the marker once a human has reviewed the file and stands behind it. A mar

# Judgement calls

- **Build on the concepts that are already there.** Before changing or extending code, look at how the surrounding code — the sibling roles, the neighbouring functions — already solves the same thing, and use that same concept. A second mechanism for something the code already answers makes it harder to understand, even where it looks cleaner locally: a small wart such as an unused `_param` costs less than a new concept. Two exceptions: a file with `AI-GENERATED — not an architecture reference` on its first line is no model (see above), and where this document names a pattern as legacy (TypeORM, throwing expected failures), the document wins.
- Prefer moving code to the new architecture over duplicating it into both.
- When old and new coexist for the same concern, the new location is the single source of truth; the old path delegates to it rather than reimplementing.
- Pure refactoring means no behaviour change: same inputs, same outputs, same side effects, same errors.
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"cors": "^2.8.5",
"database": "*",
"dotenv": "^10.0.0",
"drizzle-orm": "^0.44.7",
"esbuild": "^0.25.2",
"express": "^4.17.21",
"express-slow-down": "^2.0.1",
Expand Down
6 changes: 3 additions & 3 deletions backend/src/auth/RESTRICTED_WHILE_UNCONFIRMED.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ export const RESTRICTED_WHILE_UNCONFIRMED = [
// The first creation books Gradido through its own interaction, not through
// createContribution, so the CREATE_CONTRIBUTION entry above does not reach it.
RIGHTS.FIRST_CREATION,
// A table code vouches for a guest (E-017), and only a confirmed member vouches (E-018):
// the presenceCode query refuses an unconfirmed address at once, inside the 24 hours as
// A guarantor code vouches for a guest (E-017), and only a confirmed member vouches (E-018):
// the guarantorCode query refuses an unconfirmed address at once, inside the 24 hours as
// well, because this list only takes hold once they are over. The entry stays as the second
// bolt for the time after them.
RIGHTS.PRESENCE_CODE,
RIGHTS.GUARANTOR_CODE,
]
4 changes: 2 additions & 2 deletions backend/src/auth/RIGHTS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,10 @@ export enum RIGHTS {
// "Show it to your friends" (ZE-005): who brought the caller here, and who arrived over
// them last. Reading only, and only about the caller - the query takes no argument.
SHOW_FRIENDS = 'SHOW_FRIENDS',
// The table code (E-017): a fresh signed code for the caller's own card, so that a guest
// The guarantor code (E-017): a fresh signed code for the caller's own card, so that a guest
// who scans it may choose a password at once. It vouches for somebody - see
// RESTRICTED_WHILE_UNCONFIRMED.
PRESENCE_CODE = 'PRESENCE_CODE',
GUARANTOR_CODE = 'GUARANTOR_CODE',
// ES-021, the project account. Both act on the caller's OWN account only: declaring it a
// project account (switches creation off, at once) and asking for the creation right
// back (a mail to the support — it switches nothing; an administrator does that).
Expand Down
4 changes: 2 additions & 2 deletions backend/src/auth/USER_RIGHTS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ export const USER_RIGHTS = [
// reaches nobody else's data, so there is nothing an unconfirmed or a project account
// could do with it that anybody else could not.
RIGHTS.SHOW_FRIENDS,
// The table code: minted in the caller's own name only, the query takes no argument.
RIGHTS.PRESENCE_CODE,
// The guarantor code: minted in the caller's own name only, the query takes no argument.
RIGHTS.GUARANTOR_CODE,
// ES-021: both reach the caller's own account and nothing else. Neither is on
// RESTRICTED_FOR_PROJECT_ACCOUNT — the way back (a request to the support) has to stay
// open to the very account that is locked out of creating.
Expand Down
46 changes: 46 additions & 0 deletions backend/src/data/AccountState.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// AI-GENERATED — not an architecture reference
import { AccountState } from 'database'
import { PasswordEncryptionType } from 'shared'
import { accountStateFromFields } from './AccountState.logic'

const local = {
foreign: false,
referrerId: null,
passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD,
}

describe('accountStateFromFields', () => {
it('is FOREIGN for a copy of another community, whatever else it holds', () => {
expect(accountStateFromFields({ ...local, foreign: true }, true)).toBe(AccountState.FOREIGN)
})

it('is ACTIVATED once the address is confirmed, guest of a table or not', () => {
expect(accountStateFromFields(local, true)).toBe(AccountState.ACTIVATED)
expect(
accountStateFromFields(
{ ...local, referrerId: 7, passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID },
true,
),
).toBe(AccountState.ACTIVATED)
})

it('is PARTLY_ACTIVATED_GUARANTOR for an unconfirmed account with referrer and password', () => {
expect(
accountStateFromFields(
{ ...local, referrerId: 7, passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID },
false,
),
).toBe(AccountState.PARTLY_ACTIVATED_GUARANTOR)
})

it('is REGISTERED for an unconfirmed classic registration, referred or not', () => {
expect(accountStateFromFields(local, false)).toBe(AccountState.REGISTERED)
expect(accountStateFromFields({ ...local, referrerId: 7 }, false)).toBe(AccountState.REGISTERED)
expect(
accountStateFromFields(
{ ...local, passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID },
false,
),
).toBe(AccountState.REGISTERED)
})
})
30 changes: 30 additions & 0 deletions backend/src/data/AccountState.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// AI-GENERATED — not an architecture reference
import { AccountState } from 'database'
import { PasswordEncryptionType } from 'shared'

/**
* The state a not deleted account stands in, derived from the fields that decided it before
* `account_state` existed - the same rule migration 0148 filled the column with. For an
* account coming back from DELETED, whose earlier state the column no longer holds.
*
* TODO: gone once every transition is written where it happens - see the issue "Complete
* refactor for account state".
*/
export const accountStateFromFields = (
user: { foreign: boolean; referrerId?: number | null; passwordEncryptionType: number },
emailChecked: boolean,
): AccountState => {
if (user.foreign) {
return AccountState.FOREIGN
}
if (emailChecked) {
return AccountState.ACTIVATED
}
if (
(user.referrerId ?? null) !== null &&
user.passwordEncryptionType !== PasswordEncryptionType.NO_PASSWORD
) {
return AccountState.PARTLY_ACTIVATED_GUARANTOR
}
return AccountState.REGISTERED
}
11 changes: 11 additions & 0 deletions backend/src/data/CodeType.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// AI-GENERATED — not an architecture reference

/**
* What a sealed code is for, carried in a byte of its own inside the sealed block - the byte
* that would otherwise be the most significant one of the expiry. Starts at 1: that byte of any
* valid expiry is 0 (for the next 2.28 billion years), so a type is never mistaken for part of
* an expiry, and a block that holds a bare expiry is no code of any type.
*/
export enum CodeType {
GUARANTOR = 1,
}
225 changes: 225 additions & 0 deletions backend/src/data/GuarantorCode.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
// AI-GENERATED — not an architecture reference
import { createCipheriv, createHmac } from 'node:crypto'
import { CONFIG } from '@/config'
import { CodeType } from './CodeType.enum'
import {
GUARANTOR_CODE_VALID_MINUTES,
mintGuarantorCode,
verifyGuarantorCode,
} from './GuarantorCode.logic'

const USER_ID = 4711
const COMMUNITY = '0b9e5c2e-8d7a-4a8f-9d3f-4c7d2f6a1b01'
const OTHER_COMMUNITY = '6f1c7a5e-2b3d-4e8f-a1b2-c3d4e5f60718'
const NOW = new Date('2026-09-22T10:00:00.000Z')
const minutes = (n: number): Date => new Date(NOW.getTime() + n * 60 * 1000)
const nowSeconds = NOW.getTime() / 1000

// Seals a block the way the server does, for the cases no minted code can produce: another
// type, an expiry outside the window. Only the check behind the seal is under test here.
const sealedCode = (userId: bigint, type: number, exp: bigint): string => {
const key = createHmac('sha256', CONFIG.JWT_SECRET).update(`guarantor-code|${COMMUNITY}`).digest()
const block = Buffer.alloc(16)
block.writeBigUInt64BE(userId, 0)
block.writeBigUInt64BE((BigInt(type) << 56n) | (exp & ((1n << 56n) - 1n)), 8)
const cipher = createCipheriv('aes-256-ecb', key, null)
cipher.setAutoPadding(false)
return `${exp}.${Buffer.concat([cipher.update(block), cipher.final()]).toString('base64url')}`
}

describe('GuarantorCode.logic', () => {
describe('mintGuarantorCode', () => {
it('runs out after the valid minutes, to the second', () => {
const { code, expiresAt } = mintGuarantorCode(USER_ID, COMMUNITY, NOW)

expect(expiresAt).toEqual(minutes(GUARANTOR_CODE_VALID_MINUTES))
expect(code.split('.')[0]).toBe(String(expiresAt.getTime() / 1000))
})

// What a device counts down from the moment the answer arrives: the time left at `now`,
// so the whole ten minutes on a whole second and less by the part of the second gone.
it('says how much of the validity is left, to the millisecond', () => {
const validMs = GUARANTOR_CODE_VALID_MINUTES * 60 * 1000
const partway = new Date(NOW.getTime() + 400)

expect(mintGuarantorCode(USER_ID, COMMUNITY, NOW).remainingMs).toBe(validMs)
expect(mintGuarantorCode(USER_ID, COMMUNITY, partway).remainingMs).toBe(validMs - 400)
const { expiresAt, remainingMs } = mintGuarantorCode(USER_ID, COMMUNITY, partway)
expect(expiresAt.getTime() - partway.getTime()).toBe(remainingMs)
})

it('has the shape <unix seconds>.<22 base64url characters>', () => {
expect(mintGuarantorCode(USER_ID, COMMUNITY, NOW).code).toMatch(/^\d{10}\.[A-Za-z0-9_-]{22}$/)
})

it('is a new code a second later, and another for another member', () => {
const later = new Date(NOW.getTime() + 1000)
const code = mintGuarantorCode(USER_ID, COMMUNITY, NOW).code

expect(mintGuarantorCode(USER_ID, COMMUNITY, later).code).not.toBe(code)
expect(mintGuarantorCode(USER_ID + 1, COMMUNITY, NOW).code).not.toBe(code)
})

// The block is encrypted: the user id is inside, but nothing outside can read it.
it('does not show the user id', () => {
const block = Buffer.from(
mintGuarantorCode(USER_ID, COMMUNITY, NOW).code.split('.')[1],
'base64url',
)

expect(block.readBigUInt64BE(0)).not.toBe(BigInt(USER_ID))
})

// A code for nobody would be a bug in the caller, not something to hand out.
it('refuses a missing user id or community', () => {
expect(() => mintGuarantorCode(0, COMMUNITY, NOW)).toThrow()
expect(() => mintGuarantorCode(-1, COMMUNITY, NOW)).toThrow()
expect(() => mintGuarantorCode(1.5, COMMUNITY, NOW)).toThrow()
expect(() => mintGuarantorCode(USER_ID, '', NOW)).toThrow()
})
})

describe('verifyGuarantorCode', () => {
const { code } = mintGuarantorCode(USER_ID, COMMUNITY, NOW)

it('names the member the code was minted for', () => {
expect(verifyGuarantorCode(code, COMMUNITY, NOW)).toBe(USER_ID)
})

it('carries the largest user id JavaScript holds exactly', () => {
const largest = mintGuarantorCode(Number.MAX_SAFE_INTEGER, COMMUNITY, NOW).code

expect(verifyGuarantorCode(largest, COMMUNITY, NOW)).toBe(Number.MAX_SAFE_INTEGER)
})

it('accepts it until the last second before it runs out', () => {
const lastSecond = new Date(minutes(GUARANTOR_CODE_VALID_MINUTES).getTime() - 1000)

expect(verifyGuarantorCode(code, COMMUNITY, lastSecond)).toBe(USER_ID)
})

it('refuses it once it has run out', () => {
expect(verifyGuarantorCode(code, COMMUNITY, minutes(GUARANTOR_CODE_VALID_MINUTES))).toBeNull()
expect(verifyGuarantorCode(code, COMMUNITY, minutes(60))).toBeNull()
})

// A server whose clock runs a little behind the one that minted still takes the code.
it('accepts a fresh code from a server whose clock runs up to a minute ahead', () => {
expect(verifyGuarantorCode(code, COMMUNITY, new Date(NOW.getTime() - 60 * 1000))).toBe(
USER_ID,
)
expect(verifyGuarantorCode(code, COMMUNITY, new Date(NOW.getTime() - 61 * 1000))).toBeNull()
})

it('refuses an altered block', () => {
const [exp, block] = code.split('.')
const altered = `${exp}.${block[0] === 'A' ? 'B' : 'A'}${block.slice(1)}`

expect(verifyGuarantorCode(altered, COMMUNITY, NOW)).toBeNull()
})

// The low 7 bytes of the expiry are inside the block: moving it is a broken code.
it('refuses a code whose expiry was pushed later or earlier', () => {
const [exp, block] = code.split('.')

expect(verifyGuarantorCode(`${Number(exp) + 30}.${block}`, COMMUNITY, NOW)).toBeNull()
expect(verifyGuarantorCode(`${Number(exp) - 30}.${block}`, COMMUNITY, NOW)).toBeNull()
})

// The byte the block leaves out: the most significant one of the expiry. Setting it would
// keep the check inside the block intact - the bound is what refuses it.
it('refuses a code whose expiry was pushed by its most significant byte', () => {
const [exp, block] = code.split('.')
const pushed = BigInt(exp) + (1n << 56n)

expect(verifyGuarantorCode(`${pushed}.${block}`, COMMUNITY, NOW)).toBeNull()
})

it('refuses a sealed code whose expiry lies further ahead than a code is ever valid', () => {
const tooLate = BigInt(nowSeconds + GUARANTOR_CODE_VALID_MINUTES * 60 + 61)

expect(
verifyGuarantorCode(
sealedCode(BigInt(USER_ID), CodeType.GUARANTOR, tooLate),
COMMUNITY,
NOW,
),
).toBeNull()
})

it('refuses a sealed block of another type', () => {
const exp = BigInt(nowSeconds + 60)

expect(
verifyGuarantorCode(sealedCode(BigInt(USER_ID), CodeType.GUARANTOR, exp), COMMUNITY, NOW),
).toBe(USER_ID)
expect(verifyGuarantorCode(sealedCode(BigInt(USER_ID), 0, exp), COMMUNITY, NOW)).toBeNull()
expect(
verifyGuarantorCode(
sealedCode(BigInt(USER_ID), CodeType.GUARANTOR + 1, exp),
COMMUNITY,
NOW,
),
).toBeNull()
})

it('refuses a sealed block for user id 0 or one beyond what JavaScript holds exactly', () => {
const exp = BigInt(nowSeconds + 60)

expect(
verifyGuarantorCode(sealedCode(0n, CodeType.GUARANTOR, exp), COMMUNITY, NOW),
).toBeNull()
expect(
verifyGuarantorCode(
sealedCode(BigInt(Number.MAX_SAFE_INTEGER) + 1n, CodeType.GUARANTOR, exp),
COMMUNITY,
NOW,
),
).toBeNull()
})

it('refuses another community', () => {
expect(verifyGuarantorCode(code, OTHER_COMMUNITY, NOW)).toBeNull()
})

// 22 characters hold 132 bits for 128: the last one has spellings the server never writes.
it('takes only the spelling the server writes', () => {
const [exp, block] = code.split('.')
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
const last = alphabet.indexOf(block[21])
const sameBytes = `${block.slice(0, 21)}${alphabet[last ^ 1]}`

expect(Buffer.from(sameBytes, 'base64url')).toEqual(Buffer.from(block, 'base64url'))
expect(verifyGuarantorCode(`${exp}.${sameBytes}`, COMMUNITY, NOW)).toBeNull()
expect(verifyGuarantorCode(`0${code}`, COMMUNITY, NOW)).toBeNull()
})

it('refuses a broken shape', () => {
for (const broken of [
'',
'x',
'1.2.3',
code.split('.')[0],
`${code}x`,
`${code.slice(0, -1)}`,
]) {
expect(verifyGuarantorCode(broken, COMMUNITY, NOW)).toBeNull()
}
})

// The key is derived from the session secret: a code minted under one secret means
// nothing to a server that holds another.
it('refuses a code minted under a different secret', () => {
const secret = CONFIG.JWT_SECRET
try {
CONFIG.JWT_SECRET = 'another secret'
const foreign = mintGuarantorCode(USER_ID, COMMUNITY, NOW).code
CONFIG.JWT_SECRET = secret

expect(verifyGuarantorCode(foreign, COMMUNITY, NOW)).toBeNull()
} finally {
CONFIG.JWT_SECRET = secret
}
})
})
})
Loading
Loading