Author SHA1 Message Date
jb e67c3d4cdb feat: add TOTP multifactor authentication 2026-08-07 13:10:05 -04:00
jb 4777a3abd5 feat: refine builder skill chip flows
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 44s
Validate and deploy staging / deploy (push) Successful in 1m14s
2026-08-06 01:00:15 -04:00
jb 46f64d37b6 fix: serialize article autosaves
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 33s
Validate and deploy staging / deploy (push) Successful in 1m11s
2026-08-04 12:45:29 -04:00
jb 7ca6cb8458 Updating mechanics for OS protocols
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 33s
Validate and deploy staging / deploy (push) Successful in 1m9s
2026-08-03 16:12:20 -04:00
89 changed files with 8909 additions and 961 deletions
+4
View File
@@ -41,6 +41,10 @@ ORIGIN=https://dev.shd.build
# accessing development directly at http://localhost:5173.
PASSKEY_RP_ID=shd.build
# One to three comma-separated key-id:canonical-base64url-32-byte-key entries.
# The first key is used for new MFA TOTP factors; retain old keys for rotation.
MFA_TOTP_ENCRYPTION_KEYS=
# SMTP delivery for email verification and magic-link authentication.
# Port 465 normally uses SMTP_SECURE=true. Port 587 normally uses false and STARTTLS.
SMTP_HOST=
+17 -2
View File
@@ -32,8 +32,23 @@ npm run db:seed-users
The seed creates `Aewyn` and `Quartermaster` with the standard user role. It
generates and displays strong temporary passwords once, then stores only salted
Argon2id hashes. The separate bootstrap policy promotes the first authenticated
account when no administrator exists.
Argon2id hashes. `DEFAULT_ADMIN_PASSWORD` is the password variable name for
`Aewyn`; it does not grant administrator authority.
Sign-in never grants administrator authority automatically. For local
development, give the target account a verified email, enroll TOTP at
`/account/security`, and save its one-time recovery codes. Stop the development
server, then run:
```sh
npm run admin:bootstrap -- <verified-email>
```
The offline command requires an active TOTP factor and an unused recovery code,
revokes the target account's sessions, and refuses to run when an active full
administrator already exists. Restart the development server after it succeeds.
See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for staging and production
operation.
## Develop
+68 -1
View File
@@ -555,7 +555,7 @@ CREATE TABLE IF NOT EXISTS equipment_types (id TEXT PRIMARY KEY, name TEXT NOT N
CREATE TABLE IF NOT EXISTS stat_definitions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, value_kind TEXT NOT NULL, min_value REAL, max_value REAL, unit TEXT, active INTEGER NOT NULL DEFAULT 1);
CREATE TABLE IF NOT EXISTS stat_equipment_types (stat_id TEXT NOT NULL REFERENCES stat_definitions(id), equipment_type_id TEXT NOT NULL REFERENCES equipment_types(id), PRIMARY KEY(stat_id, equipment_type_id));
CREATE TABLE IF NOT EXISTS audit_log (id TEXT PRIMARY KEY, actor_id TEXT NOT NULL REFERENCES users(id), entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, action TEXT NOT NULL, before_json TEXT, after_json TEXT, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, aal INTEGER NOT NULL DEFAULT 1 CHECK(aal IN (1,2)), aal_verified_at TEXT NOT NULL, aal2_verified_at TEXT);
CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT);
CREATE TABLE IF NOT EXISTS deleted_roles (role_id TEXT PRIMARY KEY, deleted_at TEXT NOT NULL, deleted_by TEXT NOT NULL REFERENCES users(id));
CREATE TABLE IF NOT EXISTS permissions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT);
@@ -591,6 +591,8 @@ CREATE TABLE IF NOT EXISTS login_events (
session_ended_at TEXT,
session_end_reason TEXT,
sensitive_purged_at TEXT,
aal INTEGER CHECK(aal IS NULL OR aal IN (1,2)),
aal2_verified_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_emails (
@@ -643,6 +645,60 @@ CREATE TABLE IF NOT EXISTS deleted_accounts (
reason TEXT
);
CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC);
CREATE TABLE IF NOT EXISTS mfa_totp_factors (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
state TEXT NOT NULL CHECK(state IN ('pending','active','revoked')),
encrypted_secret TEXT NOT NULL,
encryption_key_id TEXT NOT NULL,
issuer TEXT NOT NULL DEFAULT 'SHD.build',
account_label TEXT NOT NULL,
algorithm TEXT NOT NULL DEFAULT 'SHA1' CHECK(algorithm='SHA1'),
digits INTEGER NOT NULL DEFAULT 6 CHECK(digits=6),
period_seconds INTEGER NOT NULL DEFAULT 30 CHECK(period_seconds=30),
last_counter INTEGER,
failed_attempts INTEGER NOT NULL DEFAULT 0 CHECK(failed_attempts BETWEEN 0 AND 5),
locked_until TEXT,
expires_at TEXT,
created_at TEXT NOT NULL,
activated_at TEXT,
revoked_at TEXT,
CHECK((state='pending' AND expires_at IS NOT NULL AND activated_at IS NULL AND revoked_at IS NULL)
OR (state='active' AND activated_at IS NOT NULL AND expires_at IS NULL AND revoked_at IS NULL)
OR (state='revoked' AND revoked_at IS NOT NULL))
);
CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_active_idx
ON mfa_totp_factors(user_id) WHERE state='active';
CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_pending_idx
ON mfa_totp_factors(user_id) WHERE state='pending';
CREATE INDEX IF NOT EXISTS mfa_totp_factors_expiry_idx
ON mfa_totp_factors(state,expires_at) WHERE state='pending';
CREATE TABLE IF NOT EXISTS mfa_recovery_codes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
consumed_at TEXT,
revoked_at TEXT,
UNIQUE(user_id,code_hash)
);
CREATE INDEX IF NOT EXISTS mfa_recovery_codes_available_idx
ON mfa_recovery_codes(user_id,consumed_at,revoked_at);
CREATE TABLE IF NOT EXISTS authentication_transactions (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT CHECK(provider IS NULL OR provider IN ('password','discord','magic_link','passkey')),
kind TEXT NOT NULL CHECK(kind IN ('login','step_up')),
return_path TEXT NOT NULL CHECK(length(return_path) BETWEEN 1 AND 2048 AND substr(return_path,1,1)='/'),
required_aal INTEGER NOT NULL DEFAULT 2 CHECK(required_aal=2),
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count BETWEEN 0 AND 5),
expires_at TEXT NOT NULL,
consumed_at TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS authentication_transactions_expiry_idx
ON authentication_transactions(expires_at);
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-editor','Data Editor','Access to ISAC game-data management');
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-user-operator','User Operator','View and moderate SHD.build users');
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-access-manager','Access Manager','Manage user roles and direct permissions');
@@ -1422,6 +1478,17 @@ CREATE TABLE IF NOT EXISTS specialization_ability_attacks (
CHECK(charge_policy<>'none' OR charge_count=1)
);
CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name);
CREATE TABLE IF NOT EXISTS specialization_ability_skill_chip_links (
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
compatible_ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
PRIMARY KEY(ability_id,compatible_ability_id),
CHECK(ability_id<compatible_ability_id)
);
CREATE INDEX IF NOT EXISTS specialization_ability_skill_chip_links_compatible_idx
ON specialization_ability_skill_chip_links(compatible_ability_id,ability_id);
-- db-migrate.mjs installs insert/update triggers for active same-specialization
-- endpoints and one-pair-per-ability enforcement. Trigger bodies cannot live in
-- this semicolon-delimited schema file.
CREATE TABLE IF NOT EXISTS specialization_ability_effects (
id TEXT PRIMARY KEY,
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
+6
View File
@@ -140,6 +140,10 @@ describe('container runtime startup', () => {
writeFile(join(temporaryRoot, 'chown'), ownershipStub, { mode: 0o755 }),
writeExport(appExport, [
{ key: 'APP_ORIGIN', value: 'https://exported.invalid' },
{
key: 'MFA_TOTP_ENCRYPTION_KEYS',
value: 'primary:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
},
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
]),
@@ -218,6 +222,8 @@ describe('container runtime startup', () => {
);
expect(childEnvironment).toMatchObject({
APP_ORIGIN: 'https://stage.shd.build',
MFA_TOTP_ENCRYPTION_KEYS:
'primary:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
DISCORD_CLIENT_ID: 'exported-discord-client',
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
ORIGIN: 'https://stage.shd.build',
+81 -3
View File
@@ -98,9 +98,81 @@ remove the old dotenv private keys and `SCHEDULER_TOKEN_PRODUCTION` from Gitea,
and delete local and hosted dotenv key material. Removing encrypted files from
the current Git tree does not revoke values stored in Git history.
The staging container disables default-user seeding. On a brand-new staging
database, the existing bootstrap policy grants Admin to the first real account
that signs in instead of writing generated credentials into deployment logs.
## Authentication bootstrap and MFA secrets
Set the required app-only `MFA_TOTP_ENCRYPTION_KEYS` secret under `/app` in the
`dev`, `staging`, and `prod` Infisical environments before starting each app.
The value contains one to three comma-separated, unique
`key-id:canonical-base64url-32-byte-key` entries. Each key is a random 32-byte
value encoded as canonical Base64URL. The first entry is the active write key;
following entries are retained decryption keys for older TOTP factors. Do not
put this secret under `/shared/scheduler`, in a Gitea variable, in the image, or
in a deployment log. A missing, malformed, duplicate, or overlong keyring fails
app runtime validation.
Rotate the keyring without losing access to existing factors:
1. Generate a new random 32-byte key in the approved secret-management process
and assign it a new key ID. Do not copy the value into a document or shell
history.
2. Prepend the new `key-id` entry and retain the old entries in Infisical.
3. Deploy or restart the app, then verify an existing TOTP sign-in and a new
enrollment. A successful verification rewraps that factor under the first
key.
4. Retain each old key until every factor encrypted with it has been rewrapped
and verified. Never remove old entries merely because new enrollments use the
new key; losing a required old key prevents decryption of those factors.
TOTP enrollment issues ten recovery codes and displays them once. The database
stores only their domain-separated hashes. The account owner must save the codes
in an offline password manager or another protected offline record; do not put
their values in Infisical, logs, tickets, backups, audit snapshots, or this
documentation. Each code works once. Regenerating codes immediately revokes all
unused previous codes and displays a replacement set once. Disabling TOTP also
revokes its recovery codes.
Sign-in never grants administrator authority automatically. Before offline
elevation, the target administrator must have a verified email, an active TOTP
factor, and at least one unused recovery code. The target enrolls TOTP at
`/account/security` while the app is running. Then stop every app and scheduler
service that can access the database and run the environment-specific command.
The command requires that no active full administrator already exists, assigns
the canonical administrator role, revokes the target's sessions, and records
`role_management / administrator.bootstrapped`.
For local development:
```sh
npm run admin:bootstrap -- <verified-email>
```
For staging, from `/opt/shd-build-stage`:
```sh
docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \
app node scripts/admin-bootstrap.mjs <verified-email>
docker compose --env-file .env.deploy -f compose.staging.yml up -d app scheduler
```
For production, from `/opt/shd-build-production`:
```sh
docker compose --env-file .env.deploy -f compose.production.yml stop scheduler app
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \
app node scripts/admin-bootstrap.mjs <verified-email>
docker compose --env-file .env.deploy -f compose.production.yml up -d app scheduler
```
The script requires exactly one active, unblocked account with a verified email,
an active TOTP factor, and an unused recovery code. It exits without changing
roles when an active full administrator already exists. The Compose commands
restart their services; restart the local development server after its command.
The target must sign in again because its old sessions were revoked.
The staging container disables default-user seeding. A first sign-in never
receives Admin; use the offline flow above after a new database or an initial
data bootstrap.
## Gitea Actions runner
@@ -388,6 +460,12 @@ import. The importer verifies the bundle target, creates a production safety
backup, and refuses to replace a production database containing users, builds,
audit events, or proposals. Remove the remote host copy after verification.
If the imported database has no active full administrator, complete the offline
administrator flow with the production Compose command in
[Authentication bootstrap and MFA secrets](#authentication-bootstrap-and-mfa-secrets)
before reopening the site. Do not rely on the first production sign-in for
elevation.
### Promote one News article
Export a locally authored News article by ID or slug while the development
+57 -27
View File
@@ -341,17 +341,36 @@ squeezed beside long module headings on mobile.
### Command Grid builder
The production builder uses Command Grid as its loadout workbench. Keep the
entire build visible in a dense matrix while one connected editor configures the
selected cell. The full-width Specialization and Focus cell is the root choice.
Its three supplied abilities appear directly below it, followed by two weapons,
six gear positions, and one full-width OS Protocol.
entire build visible in a compact matrix while one connected editor configures
the selected cell. The editor uses two lanes when its container has enough
width. Equipment identity and base values occupy the first lane; allocations,
rolls, Talents, and upgrades occupy the second. The lanes stack when either
would become too narrow. The full-width Specialization and Focus cell is the
root choice. Its three supplied abilities appear directly below it, followed by
two weapons, six gear positions, and one full-width OS Protocol.
Matrix cells summarize selected Talent names, Attribute names and values, Brand
Sets, or Skill Chips. Do not replace those choices with configuration counts.
Selecting an ability opens its three attached Skill Chip positions. Selecting
equipment opens the searchable real-data catalog and its compatible
configuration controls. Do not display a prototype option that the current
loadout schema cannot save.
Matrix cells show the slot and equipped item at rest. The selected cell reveals
its compact Talent, Attribute, Brand Set, or Skill Chip summary. This
progressive disclosure keeps the matrix scannable without replacing configured
choices with counts. Selecting an ability opens its three attached Skill Chip
positions. Selecting equipment opens the searchable real-data catalog and its
compatible configuration controls. Large Talent pools show the selected Talent
at rest and open their search grid through an explicit Change action. Do not
display a prototype option that the current loadout schema cannot save.
Builder cells use one leading icon track. Specializations, abilities, Skill
Chips, and OS Protocols use registered artwork; weapons and gear use static
category or slot fallbacks when no item artwork exists. Skill Chip slots keep
the icon beside the set name and quality so empty and configured slots share one
scan path. Visible builder labels stay short; full validation text remains
available through the cell's accessible name or title.
Each equipped gear or weapon Talent exposes two adjacent icon choices for Tier I
and Tier II. Both choices reuse the Talent artwork; Tier II applies a restrained
warm recolor so players can distinguish the pair before reading the labels.
Every tile retains `T1` or `T2`, its full tier name, and selected or unavailable
text. Disable Tier II when the canonical Talent has no authored Tier II data.
Weapon equipment tier and Talent tier are independent selections.
Equipment readiness belongs at the point of resolution. Mark an incomplete
matrix cell with a compact red text indicator and status rail, then let the cell
@@ -361,20 +380,19 @@ and Skill DPS, encounter assumptions, activation profiles, rotations, and
simulation as `COMING SOON`; they do not report calculation-profile blockers as
current player errors.
The builder uses three workbench columns on wide screens, moves the live summary
below the matrix and editor at intermediate widths, and stacks all three regions
on narrow screens. Every grid track and editor child must accept content
shrinkage. At 560px and below, stack attribute picker/value pairs and section
headings; keep the command actions in two equal columns. Validation markers use
short visible labels while their full messages remain available to assistive
technology and pointer users.
The builder uses two primary workbench columns on wide screens: the compact
matrix and the two-lane editor. The live summary spans both columns below them.
Narrow screens stack the matrix, editor, and summary. Every grid track and
editor child must accept content shrinkage. At 560px and below, stack Attribute
picker/value pairs and section headings; keep the command actions in two equal
columns. Validation markers use short visible labels while their full messages
remain available to assistive technology and pointer users.
Use `--shd-builder-canvas` for recessed equipment fields. Use
`--shd-builder-selection` with `--shd-builder-selection-border` for the selected
cell. Preserve button semantics, `aria-pressed`, focus-visible outlines, pressed
feedback, and textual readiness states. At narrower desktop widths, keep the
matrix and editor together and move live results below them. On mobile, stack
the matrix, editor, and live results in that order.
feedback, and textual readiness states. On mobile, stack the matrix, editor, and
live results in that order.
The command header contains the required build name, local and account save
actions, account visibility, and optional sharing notes. Sharing notes include a
@@ -2283,6 +2301,13 @@ active abilities, each ability has exactly three active Skill Chip definitions,
tree rows are contiguous, at least one paid node exists, and the tree uses the
configured available point total.
The ability editor may pair the open ability with one active ability from the
same specialization for Skill Chip sharing. Present this as an explicit optional
relationship, exclude the current ability, and disable abilities that already
belong to another pair. Do not infer compatibility from names or Focus
membership. Saving either side updates the shared pair and includes it in the
ability audit snapshot. Archiving either ability clears the pair.
Compose focus identity from uploaded specialization artwork and the canonical
Firepower, Toughness, or Engineering icon. Keep the specialization art primary
and place the attribute mark in a smaller corner badge. Mirror ability slots 1
@@ -2575,7 +2600,7 @@ conditional Talent outcomes, weapon and Skill DPS, encounter assumptions,
activation profiles, rotations, and simulation behind explicit `COMING SOON`
panels.
Loadout schema version 5 retains `maximum`, `custom`, conditional state,
Loadout schema version 6 retains `maximum`, `custom`, conditional state,
encounter-assumption, and per-ability activation fields. Version 4 is obsolete
prototype data rather than a compatibility format. The current player UI ignores
the simulation fields. When player simulation ships, one required two-option
@@ -2630,12 +2655,17 @@ The builder presents Specialization and Focus as the first loadout decision. The
Focus sets the primary Attribute, progression tree, and three ordered abilities.
Its identity combines Specialization artwork and the primary Attribute mark.
Each ability panel contains three fixed Skill Chip positions. A chip picker
shows definitions assigned to that ability and excludes definitions assigned
elsewhere. Skill Chips have no item-level control. The editor uses the shared
quality profiles for direct rolls. It labels the four ability-scoped primary
values `NOT YET DEFINED` and excludes them from current Agent and combat totals.
Secondary capacity is Standard zero, Specialized one, Superior two, and High-end
two; each equipped chip uses distinct selections from the shared pool.
shows the ability's three definitions. When ISAC explicitly pairs that ability
with one compatible ability, the picker also shows the partner's three
definitions and labels shared entries. The relationship is bidirectional and
does not apply to other abilities in the same specialization. Players still
equip three chips in each ability panel. Skill Chips have no item-level control.
The editor uses the shared quality profiles for direct rolls. It labels the four
ability-scoped primary values `NOT YET DEFINED` and excludes them from current
Agent and combat totals. Shared-chip primary values and 3-piece Talent outcomes
target the ability represented by the panel. Secondary capacity is Standard
zero, Specialized one, Superior two, and High-end two; each equipped chip uses
distinct selections from the shared pool.
The focus tree groups nodes by row and uses segmented rank controls. Disabled
ranks remain visible with their lock state. The builder permits a row after the
+38 -18
View File
@@ -26,8 +26,8 @@ Use these event boundaries:
- **Healing:** Count each healing event. A heal-over-time effect counts each
tick as a separate event.
- **Extra Health gained:** Count every Extra Health grant, including a grant
received while the Agent already has Extra Health. Use
`extra_health_granted` for the grant action.
received while the Agent already has Extra Health. Use `extra_health_granted`
for the grant action.
- **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit
it when the Agent receives a grant, when the remaining amount changes, and
when it is depleted to zero. Every event requires the boolean
@@ -35,8 +35,8 @@ Use these event boundaries:
`values.currentExtraHealth`, which must agree with that status. This state
event is distinct from the `extra_health_granted` grant action.
- **Reloading:** Trigger on reload completion when the weapon had at least one
round to load. A canceled reload does not count. A weapon that loads one
round at a time produces one completed-reload event for each round.
round to load. A canceled reload does not count. A weapon that loads one round
at a time produces one completed-reload event for each round.
- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does
not count as taking damage.
@@ -59,8 +59,8 @@ Inspiration and Crossfire use a binary deployed-Skill state:
- The benefit remains active while the Agent has at least one persistent
deployed Skill object.
- Destruction or timed expiration removes that object from the active count.
The benefit ends when no qualifying objects remain.
- Destruction or timed expiration removes that object from the active count. The
benefit ends when no qualifying objects remain.
- Instant-use Skills do not qualify.
- Multiple deployed Skill objects do not multiply the benefit.
@@ -75,8 +75,8 @@ meters:
- Moving within 30 meters of an ally who is already downed qualifies.
- Each qualifying presence refreshes the ten-second duration.
- Leaving the radius or reviving the ally does not cancel the current
ten-second duration.
- Leaving the radius or reviving the ally does not cancel the current ten-second
duration.
- The wearer does not count as an ally.
## Emergency Retreat
@@ -93,9 +93,9 @@ qualifies when the weapon had at least one round to load. This includes each
round inserted by a weapon that reloads one round at a time. A canceled reload
does not qualify.
Resolve the Extra Health amount from the Agent's displayed Firepower when
Second Wind activates. Include all equipped-item and active-buff contributions
in that Firepower value:
Resolve the Extra Health amount from the Agent's displayed Firepower when Second
Wind activates. Include all equipped-item and active-buff contributions in that
Firepower value:
- Tier I grants Extra Health equal to 600% of displayed Firepower.
- Tier II grants Extra Health equal to 840% of displayed Firepower.
@@ -103,19 +103,39 @@ in that Firepower value:
Second Wind adds its Extra Health to Extra Health from other sources. Its
five-second duration and five-second cooldown start when it activates. Expiry
removes only the Second Wind contribution. At the five-second boundary, the
prior grant can expire as a qualifying reload completion grants a new amount.
If Second Wind supplies the Agent's only Extra Health, the old amount expires
and the new amount begins at that boundary.
prior grant can expire as a qualifying reload completion grants a new amount. If
Second Wind supplies the Agent's only Extra Health, the old amount expires and
the new amount begins at that boundary.
## Nothing to Lose
Nothing to Lose uses a forward-looking deployment window:
- Each Health-damage instance makes its own 50% activation roll. Damage
absorbed by Extra Health does not roll.
- Each Health-damage instance makes its own 50% activation roll. Damage absorbed
by Extra Health does not roll.
- A successful activation applies to every Skill deployed during the five
seconds after that damage instance. Several Skills can qualify during the
same window.
seconds after that damage instance. Several Skills can qualify during the same
window.
- Calculate each reduction from the Skill's total cooldown after equipment
modifiers.
- A Skill can receive no more than three reductions during one cooldown cycle.
## OS Protocol Talents
Happy Survival restores Health once per second while it has a qualifying nearby
enemy count. Each restoration resolves 0.5% of maximum Health per enemy within
10 meters, capped at 2.5% before OS Protocol upgrade scaling.
Explosive Charge requires three seconds of uninterrupted firing. Completion
opens an eight-second window to deploy the next Skill. The window ends when the
Agent deploys that Skill. Only the enhanced Skill's first damaging hit creates
the five-meter explosion. The shared Talent cooldown rule starts its five-second
cooldown when that explosion activates.
Heavy Armor and Pummeling Shield replace and refresh their own active Extra
Health grant when the same Talent activates again. Extra Health grants from
different sources remain separate.
Pummeling Shield counts confirmed weapon hits while the Agent possesses Extra
Health. Losing all Extra Health resets the hit count. The full-Health condition
checks Health immediately before the incoming hit resolves.
+84 -24
View File
@@ -9,8 +9,8 @@ The team builds the project in vertical slices.
data
- Auth.js-compatible server authentication with Discord OAuth and credentials
providers
- Argon2id password hashing, server-side sessions, CSRF protection, and role
checks
- Argon2id password hashing, TOTP MFA with encrypted secrets, server-side
sessions, CSRF protection, and role checks
- Browser `localStorage` only for anonymous build drafts
## Product boundaries
@@ -41,6 +41,43 @@ Local credentials use Argon2id PHC strings with a unique random salt per
password. Current work factors meet OWASP's 19 MiB / 2 iteration / single-lane
minimum profile. Successful login rehashes credentials with outdated parameters.
## Authentication and MFA
Sign-in never grants administrator authority. The first full administrator is
created through an offline database operation after the target account has
enrolled TOTP. The local command is:
```sh
npm run admin:bootstrap -- <verified-email>
```
Staging and production use the stopped-container Compose workflow documented in
[`DEPLOYMENT.md`](DEPLOYMENT.md), not a host-side npm command. The bootstrap
operation accepts exactly one active, unblocked account with a verified email,
requires an active TOTP factor and an unused recovery code, requires that no
active full administrator exists, assigns the canonical `role-admin`
relationship, revokes the target's sessions, and records
`role_management / administrator.bootstrapped`. Administrators must enroll TOTP
before offline elevation.
`MFA_TOTP_ENCRYPTION_KEYS` is a required app-only secret. Its value contains one
to three comma-separated, unique `key-id:canonical-base64url-32-byte-key`
entries. The first entry is the active encryption key for new factors; later
entries retain decryption access to older factors during rotation. There is no
generated-key fallback. Rotate by prepending a newly generated 32-byte key with
a new ID, deploying it with the old entries retained, and verifying existing
TOTP authentication. A successful verification rewraps that factor under the
first key. Do not remove an old entry until every factor encrypted with it has
been rewrapped and verified. Keep key material in the secret manager and never
place it in source, logs, audit snapshots, or documentation.
TOTP enrollment issues ten one-time recovery codes. The interface displays them
only once; the database stores only domain-separated hashes. Save the codes in
an offline password manager or another protected offline record. Each code is
consumed once. Regenerating codes immediately revokes all unused previous codes
and displays a new set once. Disabling TOTP revokes its recovery codes, and a
full administrator cannot disable TOTP.
## Database runtime
The application uses Turso Database in-process through its native Node package.
@@ -244,10 +281,9 @@ does not become part of the canonical action replay.
# Administrator bootstrap
The authentication service promotes the first authenticated user to `admin` if
the database does not contain an administrator. Promotion uses one conditional
database update so concurrent first sign-ins cannot both become administrators.
Once an administrator exists, later sign-ins never alter roles.
The first full administrator is created only through the offline flow documented
in [Authentication and MFA](#authentication-and-mfa). Sign-in never grants
administrator authority or changes an account's roles.
# Database backup and restore
@@ -553,6 +589,24 @@ builder-ready only when each of its three assigned abilities has all three
active Skill Chip definitions. Skill Chip mutations refresh readiness for
focuses that reference both the previous and resulting ability.
`specialization_ability_skill_chip_links` may pair two active abilities from the
same specialization. Operators create each pair explicitly; the application does
not infer compatibility from ability names, focus membership, or shared
specialization. The pair is bidirectional, and an ability may belong to only one
pair. This permits compatible abilities such as two Tactical Link variants to
share definitions without making unrelated pairs such as two Smart Cover
variants compatible. Archiving either ability removes the pair; restoring an
ability does not recreate it.
A linked ability can equip its own three definitions or its partner's three
definitions, so each of its three Builder positions offers at most six choices.
The loadout still equips three Skill Chips per ability and nine per focus.
Ability-scoped primary values and 3-piece Talent outcomes bind to the ability
that owns the equipped position, not the definition's original owner. Two-piece
and 3-piece thresholds remain local to one equipped ability and never combine
matching definitions across ability panels. ISAC audit snapshots and game-data
transfers include the compatibility pair.
Every equipped quality counts toward the two-piece threshold. After the loadout
meets that threshold, only equipped High-end chips increase the interpolation
step. For `h` High-end chips, where `h` is zero through three, a configured
@@ -584,6 +638,9 @@ state, with sprinting taking precedence. Enabled Skill activation profiles emit
the deployed ability. An `on_event` Skill modifier applies to that activation
until the ability's next deployment or the encounter ends.
`skill_deployed.values.abilityId` and `skill_hit.values.abilityId` identify the
same ability for mechanics that bind an effect to the next deployed Skill.
Each source supplies a fixed value-variant contract:
- Standard Talents require Tier I and Tier II values. Exotic Talents require
@@ -631,11 +688,11 @@ timestamp.
the hit resolved. A producer omits the field when it lacks authoritative aiming
state; the runtime does not replace missing evidence with a scenario assumption.
Predicates compare this field explicitly with `true` or `false`; negation and
inequality cannot turn missing evidence into a match. A weapon-hit event may
also include an event-target-centered area sample. The bounded sample lists up
to 256 valid ally and enemy entity IDs with their distances from the hit target
and includes that target as an enemy at zero meters. Duplicate IDs, negative
distances, or a missing center target invalidate the evaluation.
inequality cannot turn missing evidence into a match. A `weapon_hit` or
`skill_hit` event may include an event-target-centered area sample. The bounded
sample lists up to 256 valid ally and enemy entity IDs with their distances from
the hit target and includes that target as an enemy at zero meters. Duplicate
IDs, negative distances, or a missing center target invalidate the evaluation.
`extra_health_changed` is Agent-scoped. Normalized timeline producers emit it
when the Agent receives an Extra Health grant, when the remaining amount
@@ -730,11 +787,13 @@ substitute zero.
The read-only `combat.currentHealthPercent` reference divides resolved current
Health by resolved maximum Health and returns a value from 0 through 100. The
runtime derives it when a predicate is evaluated instead of storing a second
Health value that could become stale. Current timing observes deterministic
resource changes from earlier branches at the same event. Health-band mechanics
use strict upper bounds and inclusive lower bounds, so bands do not overlap and
an exact threshold enters the next weaker band. Maximum-potential evaluation
applies the same predicates before emitting passive outcomes.
Health value that could become stale. Pre-event timing reads the combat snapshot
before mechanic branches process the event. Current timing observes
deterministic resource changes from earlier branches at the same event.
Health-band mechanics use strict upper bounds and inclusive lower bounds, so
bands do not overlap and an exact threshold enters the next weaker band.
Maximum-potential evaluation applies the same predicates before emitting passive
outcomes.
Continuous restoration resolves the outcome's total when the branch activates,
then delivers that total at a constant rate over the configured duration.
@@ -876,17 +935,18 @@ applicable, default value, and best-case value. ISAC retains these definitions
for continued mechanic authoring. Current Builder and shared Build pages do not
render or evaluate them. Their calculation panels display `COMING SOON` instead.
Loadout schema version 5 stores one focus, selected tree ranks, nine Skill
Chips, a unified conditional-effect state map, encounter assumptions,
per-ability activation controls, and `calculationMode`. Runtime readers accept
the exact version 5 structure. The schema retains its simulation fields, but the
current player UI ignores them. Version 4 is obsolete prototype data. A
backed-up prototype migration removes version 4 drafts, saved builds, and
calibration snapshots instead of translating them.
Loadout schema version 6 stores one focus, selected tree ranks, nine Skill
Chips, independent T1/T2 choices for each equipped gear and weapon Talent, a
unified conditional-effect state map, encounter assumptions, per-ability
activation controls, and `calculationMode`. Runtime readers accept the exact
version 6 structure. A backed-up migration translated version 5 equipment
Talents from their previous equipment-tier-derived behavior. The schema retains
its simulation fields, but the current player UI ignores them. Version 4 remains
obsolete prototype data.
## Planned player calculation modes
Loadout v5 defines `maximum` and `custom` modes for the planned simulation UI:
Loadout v6 defines `maximum` and `custom` modes for the planned simulation UI:
- Maximum mode supplies each scenario input's best-case value, selects peak
conditional-effect state, and runs selected abilities on cooldown against
+25
View File
@@ -21,6 +21,7 @@
"argon2": "^0.45.1",
"nodemailer": "^9.0.3",
"openai": "^6.49.0",
"otpauth": "9.5.1",
"sharp": "^0.35.3"
},
"devDependencies": {
@@ -706,6 +707,18 @@
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@oxc-project/types": {
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
@@ -3486,6 +3499,18 @@
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/otpauth": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz",
"integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.2.0"
},
"funding": {
"url": "https://github.com/hectorm/otpauth?sponsor=1"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+2
View File
@@ -16,6 +16,7 @@
"db:migrate:production": "infisical run --env=prod --path=/app --path=/shared/scheduler -- node scripts/db-migrate.mjs",
"db:seed-users": "infisical run --env=dev --path=/app -- node scripts/seed-initial-users.mjs",
"db:seed-users:reset": "infisical run --env=dev --path=/app -- node scripts/seed-initial-users.mjs --reset-passwords",
"admin:bootstrap": "infisical run --env=dev --path=/app -- node scripts/admin-bootstrap.mjs",
"db:backup": "infisical run --env=dev --path=/app -- node scripts/db-backup.mjs",
"db:backup:staging": "infisical run --env=staging --path=/app -- node scripts/db-backup.mjs",
"db:backup:production": "infisical run --env=prod --path=/app -- node scripts/db-backup.mjs",
@@ -79,6 +80,7 @@
"argon2": "^0.45.1",
"nodemailer": "^9.0.3",
"openai": "^6.49.0",
"otpauth": "9.5.1",
"sharp": "^0.35.3"
}
}
+204
View File
@@ -0,0 +1,204 @@
import { connect } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import process from 'node:process';
const usage = 'Usage: npm run admin:bootstrap -- <verified-email>';
const suppliedEmails = process.argv.slice(2);
if (suppliedEmails.length !== 1) throw new Error(usage);
const email = suppliedEmails[0]
.normalize('NFKC')
.trim()
.toLocaleLowerCase('en-US');
if (
email.length < 3 ||
email.length > 320 ||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email) ||
/[\r\n]/u.test(email)
) {
throw new Error('Provide one valid verified email address.');
}
const databasePath = resolve(
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
);
const database = await connect(databasePath);
let transactionStarted = false;
try {
const now = new Date().toISOString();
await database.exec('BEGIN IMMEDIATE');
transactionStarted = true;
const matchingAccounts = await (
await database.prepare(`
SELECT users.id
FROM users
JOIN user_emails ON user_emails.user_id=users.id
LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE user_emails.email=? COLLATE NOCASE
AND user_emails.verified_at IS NOT NULL
AND deleted_accounts.user_id IS NULL
AND user_status.blocked_at IS NULL
AND (user_status.banned_until IS NULL OR user_status.banned_until<=?)
`)
).all(email, now);
if (matchingAccounts.length !== 1)
throw new Error(
'The email must identify exactly one active verified account.',
);
const targetId = String(matchingAccounts[0].id);
const activeAdministrators = await (
await database.prepare(`
SELECT users.id
FROM users
LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE deleted_accounts.user_id IS NULL
AND user_status.blocked_at IS NULL
AND (user_status.banned_until IS NULL OR user_status.banned_until<=?)
AND EXISTS (
SELECT 1 FROM user_roles
WHERE user_roles.user_id=users.id AND user_roles.role_id='role-admin'
)
`)
).all(now);
const targetAlreadyAdministrator = activeAdministrators.some(
(administrator) => String(administrator.id) === targetId,
);
if (activeAdministrators.length > 0 && !targetAlreadyAdministrator)
throw new Error('An active full administrator already exists.');
if (!targetAlreadyAdministrator) {
const administratorRole = await (
await database.prepare(`
SELECT roles.id
FROM roles
JOIN role_permissions ON role_permissions.role_id=roles.id
JOIN permissions ON permissions.id=role_permissions.permission_id
WHERE roles.id='role-admin' AND permissions.name='system.admin'
LIMIT 1
`)
).get();
if (!administratorRole)
throw new Error('The canonical administrator role is unavailable.');
const activeFactor = await (
await database.prepare(
"SELECT id FROM mfa_totp_factors WHERE user_id=? AND state='active' LIMIT 1",
)
).get(targetId);
if (!activeFactor)
throw new Error('The target account must have an active TOTP factor.');
const unusedRecoveryCode = await (
await database.prepare(
'SELECT id FROM mfa_recovery_codes WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL LIMIT 1',
)
).get(targetId);
if (!unusedRecoveryCode)
throw new Error('The target account must have an unused recovery code.');
const before = {
roleIds: (
await (
await database.prepare(
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
)
).all(targetId)
).map((row) => String(row.role_id)),
};
await (
await database.prepare(
"INSERT OR IGNORE INTO user_roles(user_id,role_id) VALUES(?,'role-admin')",
)
).run(targetId);
await (
await database.prepare(
"UPDATE users SET role='user' WHERE id=? AND role='admin'",
)
).run(targetId);
const targetSessions = await (
await database.prepare('SELECT id FROM sessions WHERE user_id=?')
).all(targetId);
const sessionIds = targetSessions.map((row) => String(row.id));
if (sessionIds.length) {
const placeholders = sessionIds.map(() => '?').join(',');
await (
await database.prepare(
`UPDATE login_events SET session_ended_at=?,session_end_reason=? WHERE session_id IN (${placeholders})`,
)
).run(now, 'administrator_bootstrap', ...sessionIds);
await (
await database.prepare(
`DELETE FROM sessions WHERE id IN (${placeholders})`,
)
).run(...sessionIds);
}
await (
await database.prepare(`
INSERT INTO audit_log(
id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at
) VALUES(?,?,?,?,?,?,?,?)
`)
).run(
randomUUID(),
targetId,
'role_management',
targetId,
'administrator.bootstrapped',
JSON.stringify(before),
JSON.stringify({
roleId: 'role-admin',
sessionsRevoked: sessionIds.length,
mfaRequired: true,
}),
now,
);
}
const resultingAdministrators = await (
await database.prepare(`
SELECT COUNT(DISTINCT users.id) AS count
FROM users
LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE deleted_accounts.user_id IS NULL
AND user_status.blocked_at IS NULL
AND (user_status.banned_until IS NULL OR user_status.banned_until<=?)
AND EXISTS (
SELECT 1 FROM user_roles
WHERE user_roles.user_id=users.id AND user_roles.role_id='role-admin'
)
`)
).get(now);
if (Number(resultingAdministrators?.count) < 1)
throw new Error('The database must retain an active full administrator.');
await database.exec('COMMIT');
transactionStarted = false;
console.log(
targetAlreadyAdministrator
? 'The target account already has unrestricted administration.'
: 'Initial unrestricted administration granted.',
);
} catch (error) {
if (transactionStarted) {
try {
await database.exec('ROLLBACK');
} catch {
// Preserve the original failure without exposing database details.
}
}
console.error(
error instanceof Error ? error.message : 'Administrator bootstrap failed.',
);
process.exitCode = 1;
} finally {
await database.close();
}
+151
View File
@@ -0,0 +1,151 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { connect, type Database } from '@tursodatabase/database';
const run = promisify(execFile);
let directory: string;
let database: Database;
let databasePath: string;
async function invoke() {
try {
const result = await run(
process.execPath,
['scripts/admin-bootstrap.mjs', 'admin@example.com'],
{
cwd: process.cwd(),
env: {
...process.env,
TDR_DATABASE_PATH: databasePath,
NODE_ENV: 'test',
},
},
);
return { status: 0, stdout: result.stdout, stderr: result.stderr };
} catch (error) {
const failure = error as {
code?: number;
stdout?: string;
stderr?: string;
};
return {
status: Number(failure.code ?? 1),
stdout: failure.stdout ?? '',
stderr: failure.stderr ?? '',
};
}
}
describe('administrator bootstrap preconditions', () => {
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'shd-admin-bootstrap-'));
databasePath = join(directory, 'database.db');
database = await connect(databasePath);
await database.exec(`
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT, role TEXT, created_at TEXT);
CREATE TABLE user_emails (user_id TEXT, email TEXT, verified_at TEXT);
CREATE TABLE user_status (user_id TEXT PRIMARY KEY, blocked_at TEXT, banned_until TEXT);
CREATE TABLE deleted_accounts (user_id TEXT PRIMARY KEY);
CREATE TABLE roles (id TEXT PRIMARY KEY, name TEXT);
CREATE TABLE permissions (id TEXT PRIMARY KEY, name TEXT);
CREATE TABLE role_permissions (role_id TEXT, permission_id TEXT);
CREATE TABLE user_roles (user_id TEXT, role_id TEXT, PRIMARY KEY(user_id,role_id));
CREATE TABLE mfa_totp_factors (id TEXT PRIMARY KEY, user_id TEXT, state TEXT);
CREATE TABLE mfa_recovery_codes (id TEXT PRIMARY KEY, user_id TEXT, consumed_at TEXT, revoked_at TEXT);
CREATE TABLE sessions (id TEXT PRIMARY KEY, user_id TEXT);
CREATE TABLE login_events (id TEXT PRIMARY KEY, session_id TEXT, session_ended_at TEXT, session_end_reason TEXT);
CREATE TABLE audit_log (id TEXT PRIMARY KEY, actor_id TEXT, entity_type TEXT, entity_id TEXT, action TEXT, before_json TEXT, after_json TEXT, created_at TEXT);
INSERT INTO users VALUES ('user-1','agent','user','2026-08-07T00:00:00.000Z');
INSERT INTO user_emails VALUES ('user-1','admin@example.com','2026-08-07T00:00:00.000Z');
INSERT INTO roles VALUES ('role-admin','Admin');
INSERT INTO permissions VALUES ('permission-system-admin','system.admin');
INSERT INTO role_permissions VALUES ('role-admin','permission-system-admin');
`);
});
afterEach(async () => {
await database.close();
await rm(directory, { recursive: true, force: true });
});
it('rejects accounts without both active TOTP and unused recovery code', async () => {
await database.close();
const result = await invoke();
expect(result.status).toBe(1);
expect(result.stderr).toContain('active TOTP factor');
database = await connect(databasePath);
const user = await (await database.prepare('SELECT role FROM users')).get();
expect(user.role).toBe('user');
});
it('grants only after MFA readiness and records sanitized bootstrap evidence', async () => {
await database.exec(`
INSERT INTO mfa_totp_factors VALUES ('factor-1','user-1','active');
INSERT INTO mfa_recovery_codes VALUES ('code-1','user-1',NULL,NULL);
INSERT INTO sessions VALUES ('session-1','user-1');
`);
await database.close();
const result = await invoke();
expect(result.status).toBe(0);
database = await connect(databasePath);
const user = await (await database.prepare('SELECT role FROM users')).get();
const role = await (
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
).get('user-1');
const sessions = await (
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
).get();
const audit = await (
await database.prepare(
'SELECT entity_type,action,after_json FROM audit_log',
)
).get();
expect(user.role).toBe('user');
expect(role.role_id).toBe('role-admin');
expect(Number(sessions.count)).toBe(0);
expect(audit).toMatchObject({
entity_type: 'role_management',
action: 'administrator.bootstrapped',
});
expect(String(audit.after_json)).not.toContain('factor-1');
expect(String(audit.after_json)).not.toContain('code-1');
});
it('is idempotent for an already canonical administrator', async () => {
await database.exec(`
INSERT INTO mfa_totp_factors VALUES ('factor-1','user-1','active');
INSERT INTO mfa_recovery_codes VALUES ('code-1','user-1',NULL,NULL);
`);
await database.close();
expect((await invoke()).status).toBe(0);
database = await connect(databasePath);
const firstAuditCount = await (
await database.prepare(
"SELECT COUNT(*) AS count FROM audit_log WHERE action='administrator.bootstrapped'",
)
).get();
await database.close();
expect((await invoke()).status).toBe(0);
database = await connect(databasePath);
const secondAuditCount = await (
await database.prepare(
"SELECT COUNT(*) AS count FROM audit_log WHERE action='administrator.bootstrapped'",
)
).get();
expect(Number(firstAuditCount.count)).toBe(1);
expect(Number(secondAuditCount.count)).toBe(1);
});
it('keeps only the local development bootstrap command', async () => {
const packageJson = JSON.parse(await readFile('package.json', 'utf8')) as {
scripts: Record<string, string>;
};
expect(packageJson.scripts['admin:bootstrap']).toBeTruthy();
expect(packageJson.scripts['admin:bootstrap:staging']).toBeUndefined();
expect(packageJson.scripts['admin:bootstrap:production']).toBeUndefined();
});
});
+381 -7
View File
@@ -45,7 +45,8 @@ const isRoll = (value) =>
isFiniteNumber(value.value);
const isRollPair = (value) =>
Array.isArray(value) && value.length === 2 && value.every(isRoll);
const isGearSelection = (value) =>
const isTalentTier = (value) => value === 1 || value === 2;
const isLegacyGearSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
@@ -68,7 +69,7 @@ const isGearSelection = (value) =>
isRollPair(value.rolls) &&
Array.isArray(value.augmentSlots) &&
value.augmentSlots.every(isFiniteNumber);
const isWeaponSelection = (value) =>
const isLegacyWeaponSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
@@ -88,6 +89,57 @@ const isWeaponSelection = (value) =>
(key) => isFiniteNumber(value[key]),
) &&
isRollPair(value.rolls);
const isGearSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
'brandId',
'talentId',
'talentTier',
'health',
'armor',
'firepower',
'toughness',
'engineering',
'rolls',
'augmentSlots',
]) &&
['itemId', 'brandId', 'talentId'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talentTier) &&
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
isRollPair(value.rolls) &&
Array.isArray(value.augmentSlots) &&
value.augmentSlots.every(isFiniteNumber);
const isWeaponSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
'tier',
'damage',
'firepower',
'toughness',
'engineering',
'talent1Id',
'talent1Tier',
'talent2Id',
'talent2Tier',
'exoticTalentTier',
'rolls',
]) &&
['itemId', 'talent1Id', 'talent2Id'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talent1Tier) &&
isTalentTier(value.talent2Tier) &&
isTalentTier(value.exoticTalentTier) &&
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
isRollPair(value.rolls);
const isChipSelection = (value) =>
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
typeof value.setId === 'string' &&
@@ -190,7 +242,7 @@ const isCurrentLoadoutSnapshot = (raw) => {
'skillActivations',
'specialization',
]) &&
value.version === 5 &&
value.version === 6 &&
typeof value.buildName === 'string' &&
['maximum', 'custom'].includes(value.calculationMode) &&
hasExactKeys(value.gear, gearSlots) &&
@@ -212,6 +264,49 @@ const isCurrentLoadoutSnapshot = (raw) => {
);
};
const isLegacyLoadoutSnapshot = (raw) => {
let value;
try {
value = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
return false;
}
return (
hasExactKeys(value, [
'version',
'buildName',
'gear',
'weapons',
'chips',
'protocol',
'conditionalEffects',
'combatProfile',
'calculationMode',
'skillActivations',
'specialization',
]) &&
value.version === 5 &&
typeof value.buildName === 'string' &&
['maximum', 'custom'].includes(value.calculationMode) &&
hasExactKeys(value.gear, gearSlots) &&
gearSlots.every((slot) => isLegacyGearSelection(value.gear[slot])) &&
Array.isArray(value.weapons) &&
value.weapons.length === 2 &&
value.weapons.every(isLegacyWeaponSelection) &&
Array.isArray(value.chips) &&
value.chips.length === 9 &&
value.chips.every(isChipSelection) &&
isProtocolSelection(value.protocol) &&
isEffectStates(value.conditionalEffects) &&
isCombatProfile(value.combatProfile) &&
isSkillActivations(value.skillActivations) &&
hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) &&
typeof value.specialization.focusId === 'string' &&
isRecord(value.specialization.nodeRanks) &&
Object.values(value.specialization.nodeRanks).every(isFiniteNumber)
);
};
const databasePath = resolve(
process.env.TDR_DATABASE_PATH || './data/shd-build.db',
);
@@ -674,6 +769,176 @@ for (const statement of statements) {
await database.exec(statement);
}
// Authentication transactions created by older releases did not retain the
// primary provider. Keep those rows intact and leave their provider unknown;
// new login transactions write the validated provider at creation time.
const authenticationTransactionColumns = await (
await database.prepare("PRAGMA table_info('authentication_transactions')")
).all();
if (
authenticationTransactionColumns.length &&
!authenticationTransactionColumns.some((column) => column.name === 'provider')
) {
console.log('Adding primary providers to authentication transactions.');
await database.exec(
"ALTER TABLE authentication_transactions ADD COLUMN provider TEXT CHECK(provider IS NULL OR provider IN ('password','discord','magic_link','passkey'))",
);
}
// Move legacy administrator rows onto the canonical relationship before the
// legacy users.role projection is cleared. The operation is repeatable and
// keeps the existing account IDs and authored data unchanged.
const legacyAdministratorRows = await (
await database.prepare("SELECT id FROM users WHERE role='admin' ORDER BY id")
).all();
if (legacyAdministratorRows.length) {
const administratorRole = await (
await database.prepare("SELECT id FROM roles WHERE id='role-admin'")
).get();
if (!administratorRole)
throw new Error('Cannot migrate legacy administrators without role-admin.');
await database.exec('BEGIN IMMEDIATE');
try {
for (const user of legacyAdministratorRows) {
await (
await database.prepare(
"INSERT OR IGNORE INTO user_roles(user_id,role_id) VALUES(?,'role-admin')",
)
).run(String(user.id));
}
await database.exec("UPDATE users SET role='user' WHERE role='admin'");
const remainingLegacyAdministrators = await (
await database.prepare(
"SELECT COUNT(*) AS count FROM users WHERE role='admin'",
)
).get();
if (Number(remainingLegacyAdministrators?.count) !== 0)
throw new Error('Legacy administrator authority could not be cleared.');
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
}
// MFA rotations keep the current active factor while a replacement is pending.
// Older databases used one partial index for both states, which prevented that
// safe overlap. Drop it and install the two independent idempotent constraints.
const legacyTotpCurrentIndex = await (
await database.prepare(
"SELECT 1 FROM sqlite_master WHERE type='index' AND name='mfa_totp_factors_one_current_idx'",
)
).get();
if (legacyTotpCurrentIndex) {
await database.exec('DROP INDEX mfa_totp_factors_one_current_idx');
}
await database.exec(
"CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_active_idx ON mfa_totp_factors(user_id) WHERE state='active'",
);
await database.exec(
"CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_pending_idx ON mfa_totp_factors(user_id) WHERE state='pending'",
);
// Existing sessions were issued before authentication assurance levels were
// recorded. They cannot be upgraded safely, so invalidate only those legacy
// rows after adding the nullable migration columns. New sessions are written
// with a non-null AAL evidence timestamp by the auth session service.
const sessionTable = await (
await database.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='sessions'",
)
).get();
if (sessionTable) {
let sessionColumns = await (
await database.prepare("PRAGMA table_info('sessions')")
).all();
if (!sessionColumns.some((column) => column.name === 'aal')) {
await database.exec(
'ALTER TABLE sessions ADD COLUMN aal INTEGER NOT NULL DEFAULT 1 CHECK(aal IN (1,2))',
);
sessionColumns = [...sessionColumns, { name: 'aal' }];
}
if (!sessionColumns.some((column) => column.name === 'aal_verified_at')) {
await database.exec('ALTER TABLE sessions ADD COLUMN aal_verified_at TEXT');
sessionColumns = [...sessionColumns, { name: 'aal_verified_at' }];
}
if (!sessionColumns.some((column) => column.name === 'aal2_verified_at')) {
await database.exec(
'ALTER TABLE sessions ADD COLUMN aal2_verified_at TEXT',
);
}
const legacySessions = await (
await database.prepare(
'SELECT COUNT(*) AS count FROM sessions WHERE aal_verified_at IS NULL',
)
).get();
if (Number(legacySessions?.count) > 0) {
console.log(
`Invalidating ${Number(legacySessions.count)} legacy session(s) without AAL evidence.`,
);
await database.exec('DELETE FROM sessions WHERE aal_verified_at IS NULL');
}
}
const loginEventsTable = await (
await database.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='login_events'",
)
).get();
if (loginEventsTable) {
const loginEventColumns = await (
await database.prepare("PRAGMA table_info('login_events')")
).all();
if (!loginEventColumns.some((column) => column.name === 'aal')) {
await database.exec(
'ALTER TABLE login_events ADD COLUMN aal INTEGER CHECK(aal IS NULL OR aal IN (1,2))',
);
}
if (!loginEventColumns.some((column) => column.name === 'aal2_verified_at')) {
await database.exec(
'ALTER TABLE login_events ADD COLUMN aal2_verified_at TEXT',
);
}
}
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_insert
BEFORE INSERT ON specialization_ability_skill_chip_links
BEGIN
SELECT CASE WHEN EXISTS (
SELECT 1 FROM specialization_ability_skill_chip_links link
WHERE link.ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
OR link.compatible_ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
) THEN RAISE(ABORT,'Each ability can share one Skill Chip pool') END;
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM specialization_abilities ability
JOIN specialization_abilities compatible
ON compatible.id=NEW.compatible_ability_id
WHERE ability.id=NEW.ability_id
AND ability.active=1
AND compatible.active=1
AND ability.specialization_id=compatible.specialization_id
) THEN RAISE(ABORT,'Skill Chip sharing requires active abilities from one specialization') END;
END`);
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_update
BEFORE UPDATE ON specialization_ability_skill_chip_links
BEGIN
SELECT CASE WHEN EXISTS (
SELECT 1 FROM specialization_ability_skill_chip_links link
WHERE link.rowid<>OLD.rowid
AND (link.ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
OR link.compatible_ability_id IN (NEW.ability_id,NEW.compatible_ability_id))
) THEN RAISE(ABORT,'Each ability can share one Skill Chip pool') END;
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM specialization_abilities ability
JOIN specialization_abilities compatible
ON compatible.id=NEW.compatible_ability_id
WHERE ability.id=NEW.ability_id
AND ability.active=1
AND compatible.active=1
AND ability.specialization_id=compatible.specialization_id
) THEN RAISE(ABORT,'Skill Chip sharing requires active abilities from one specialization') END;
END`);
const articleRevisionColumns = await (
await database.prepare("PRAGMA table_info('article_revisions')")
).all();
@@ -2009,10 +2274,12 @@ if (
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')),
succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)),
aal INTEGER CHECK(aal IS NULL OR aal IN (1,2)),
aal2_verified_at TEXT,
created_at TEXT NOT NULL
)`);
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,created_at)
SELECT id,user_id,provider,succeeded,created_at FROM login_events_legacy`);
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,aal,aal2_verified_at,created_at)
SELECT id,user_id,provider,succeeded,NULL,NULL,created_at FROM login_events_legacy`);
await database.exec('DROP TABLE login_events_legacy');
await database.exec(
'CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC)',
@@ -2037,6 +2304,8 @@ for (const [name, definition] of [
['session_ended_at', 'TEXT'],
['session_end_reason', 'TEXT'],
['sensitive_purged_at', 'TEXT'],
['aal', 'INTEGER CHECK(aal IS NULL OR aal IN (1,2))'],
['aal2_verified_at', 'TEXT'],
]) {
if (!loginEventColumns.some((column) => column.name === name)) {
await database.exec(
@@ -2045,6 +2314,20 @@ for (const [name, definition] of [
}
}
// Earlier releases logged the primary factor as a successful login even when
// the AAL2 challenge was still pending. Retain those events as failure
// telemetry, but prevent abandoned MFA challenges from remaining successful.
await (
await database.prepare(`
UPDATE login_events
SET succeeded=0,
failure_reason=COALESCE(failure_reason,'mfa_required'),
aal=COALESCE(aal,1)
WHERE succeeded=1
AND instr(',' || COALESCE(risk_flags,'') || ',', ',primary_authenticated_mfa_required,')>0
`)
).run();
const proposalColumns = await (
await database.prepare("PRAGMA table_info('data_change_proposals')")
).all();
@@ -3223,6 +3506,41 @@ if (
}
}
const [loadoutGearRows, loadoutWeaponRows, loadoutTalentTierRows] =
await Promise.all([
(await database.prepare('SELECT id,tier FROM gear')).all(),
(await database.prepare('SELECT id,exotic_talent_id FROM weapons')).all(),
(await database.prepare('SELECT talent_id,tier FROM talent_tiers')).all(),
]);
const migrateLegacyLoadoutSnapshot = (raw) => {
if (!isLegacyLoadoutSnapshot(raw)) return null;
const value =
typeof raw === 'string' ? JSON.parse(raw) : structuredClone(raw);
for (const slot of gearSlots) {
const item = loadoutGearRows.find(
(entry) => String(entry.id) === String(value.gear[slot].itemId),
);
value.gear[slot].talentTier = Number(item?.tier) >= 2 ? 2 : 1;
}
for (const selection of value.weapons) {
const inheritedTier = Number(selection.tier) >= 2 ? 2 : 1;
selection.talent1Tier = inheritedTier;
selection.talent2Tier = inheritedTier;
const weapon = loadoutWeaponRows.find(
(entry) => String(entry.id) === String(selection.itemId),
);
const exoticTalentId = String(weapon?.exotic_talent_id ?? '');
const hasExoticTierTwo = loadoutTalentTierRows.some(
(entry) =>
String(entry.talent_id) === exoticTalentId && entry.tier === 'II',
);
selection.exoticTalentTier =
inheritedTier === 2 && hasExoticTierTwo ? 2 : 1;
}
value.version = 6;
return isCurrentLoadoutSnapshot(value) ? JSON.stringify(value) : null;
};
const buildColumns = await (
await database.prepare("PRAGMA table_info('builds')")
).all();
@@ -3237,9 +3555,34 @@ if (buildColumns.length) {
await database.exec(
"UPDATE builds SET visibility='private' WHERE visibility='draft'",
);
const builds = await (
let builds = await (
await database.prepare('SELECT id,loadout_json FROM builds')
).all();
const migratedBuilds = builds
.map((build) => ({
id: String(build.id),
loadout: migrateLegacyLoadoutSnapshot(build.loadout_json),
}))
.filter((build) => build.loadout);
if (migratedBuilds.length) {
console.log(
`Migrating ${migratedBuilds.length} build loadout snapshot(s) to version 6 Talent tiers.`,
);
await database.exec('BEGIN');
try {
for (const build of migratedBuilds)
await (
await database.prepare('UPDATE builds SET loadout_json=? WHERE id=?')
).run(build.loadout, build.id);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
builds = await (
await database.prepare('SELECT id,loadout_json FROM builds')
).all();
}
const obsoleteBuildIds = builds
.filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json))
.map((build) => String(build.id));
@@ -3629,11 +3972,42 @@ if (
);
}
const calibrationCases = await (
let calibrationCases = await (
await database.prepare(
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
)
).all();
const migratedCalibrationCases = calibrationCases
.map((calibrationCase) => ({
id: String(calibrationCase.id),
loadout: migrateLegacyLoadoutSnapshot(
calibrationCase.loadout_snapshot_json,
),
}))
.filter((calibrationCase) => calibrationCase.loadout);
if (migratedCalibrationCases.length) {
console.log(
`Migrating ${migratedCalibrationCases.length} calibration snapshot(s) to version 6 Talent tiers.`,
);
await database.exec('BEGIN');
try {
for (const calibrationCase of migratedCalibrationCases)
await (
await database.prepare(
'UPDATE calculation_calibration_cases SET loadout_snapshot_json=? WHERE id=?',
)
).run(calibrationCase.loadout, calibrationCase.id);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
calibrationCases = await (
await database.prepare(
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
)
).all();
}
const obsoleteCalibrationIds = calibrationCases
.filter(
(calibrationCase) =>
+6 -2
View File
@@ -2,7 +2,11 @@ import { connect } from '@tursodatabase/database';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'node:path';
import process from 'node:process';
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
import {
ISAC_DATA_FORMAT_VERSION,
ISAC_DATA_TABLES,
quoteIdentifier,
} from './isac-data-tables.mjs';
const databasePath = resolve(
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
@@ -67,7 +71,7 @@ for (const row of [
const snapshot = {
format: 'shd-build-isac-data',
version: 4,
version: ISAC_DATA_FORMAT_VERSION,
createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath),
totalRows,
+87 -2
View File
@@ -3,7 +3,11 @@ import { spawnSync } from 'node:child_process';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import process from 'node:process';
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
import {
ISAC_DATA_FORMAT_VERSION,
ISAC_DATA_TABLES,
quoteIdentifier,
} from './isac-data-tables.mjs';
const snapshotArgument = process.argv
.slice(2)
@@ -22,7 +26,7 @@ const snapshotPath = resolve(snapshotArgument);
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
if (
snapshot?.format !== 'shd-build-isac-data' ||
snapshot?.version !== 4 ||
snapshot?.version !== ISAC_DATA_FORMAT_VERSION ||
!Array.isArray(snapshot.tables) ||
!Array.isArray(snapshot.icons)
) {
@@ -39,6 +43,87 @@ if (
'The ISAC snapshot table manifest is incomplete or out of order.',
);
}
const abilityRows = snapshot.tables.find(
(table) => table.name === 'specialization_abilities',
).rows;
const abilitiesById = new Map(
abilityRows.map((row) => [String(row.id ?? ''), row]),
);
const skillChipRows = snapshot.tables.find(
(table) => table.name === 'skill_chip_sets',
).rows;
const activeSkillChipCounts = new Map();
for (const row of skillChipRows) {
const abilityId = String(row.specialization_ability_id ?? '');
if (!abilityId || !abilitiesById.has(abilityId)) {
throw new Error(
`The ISAC snapshot contains a Skill Chip definition with a missing ability reference "${abilityId}".`,
);
}
if (Number(row.active) !== 1) continue;
const count = (activeSkillChipCounts.get(abilityId) ?? 0) + 1;
if (count > 3) {
throw new Error(
`The ISAC snapshot assigns more than three active Skill Chip definitions to ability "${abilityId}".`,
);
}
activeSkillChipCounts.set(abilityId, count);
}
const skillChipLinks = snapshot.tables.find(
(table) => table.name === 'specialization_ability_skill_chip_links',
).rows;
const linkedAbilityIds = new Set();
for (const row of skillChipLinks) {
const abilityId = row.ability_id;
const compatibleAbilityId = row.compatible_ability_id;
if (
typeof abilityId !== 'string' ||
!abilityId ||
typeof compatibleAbilityId !== 'string' ||
!compatibleAbilityId
) {
throw new Error(
'The ISAC snapshot contains a Skill Chip sharing link with malformed ability references.',
);
}
if (abilityId === compatibleAbilityId) {
throw new Error(
`The ISAC snapshot contains a self-referencing Skill Chip sharing link for ability "${abilityId}".`,
);
}
if (abilityId >= compatibleAbilityId) {
throw new Error(
`The ISAC snapshot contains a Skill Chip sharing link that is not in canonical ability order: "${abilityId}", "${compatibleAbilityId}".`,
);
}
const ability = abilitiesById.get(abilityId);
const compatibleAbility = abilitiesById.get(compatibleAbilityId);
if (!ability || !compatibleAbility) {
throw new Error(
`The ISAC snapshot contains a Skill Chip sharing link with a missing ability reference: "${abilityId}", "${compatibleAbilityId}".`,
);
}
if (Number(ability.active) !== 1 || Number(compatibleAbility.active) !== 1) {
throw new Error(
`The ISAC snapshot contains a Skill Chip sharing link with an inactive ability: "${abilityId}", "${compatibleAbilityId}".`,
);
}
if (ability.specialization_id !== compatibleAbility.specialization_id) {
throw new Error(
`The ISAC snapshot contains a cross-specialization Skill Chip sharing link: "${abilityId}", "${compatibleAbilityId}".`,
);
}
if (
linkedAbilityIds.has(abilityId) ||
linkedAbilityIds.has(compatibleAbilityId)
) {
throw new Error(
'The ISAC snapshot contains more than one Skill Chip sharing link touching an ability.',
);
}
linkedAbilityIds.add(abilityId);
linkedAbilityIds.add(compatibleAbilityId);
}
for (const kind of ['weapon', 'gear']) {
const options = snapshot.tables.find(
(table) => table.name === `${kind}_mod_secondary_options`,
+3
View File
@@ -57,6 +57,7 @@ export const ISAC_DATA_TABLES = [
'specialization_combat_effects',
'specialization_focuses',
'specialization_abilities',
'specialization_ability_skill_chip_links',
'specialization_ability_attacks',
'specialization_ability_effects',
'specialization_focus_abilities',
@@ -75,5 +76,7 @@ export const ISAC_DATA_TABLES = [
'managed_images',
];
export const ISAC_DATA_FORMAT_VERSION = 5;
export const quoteIdentifier = (identifier) =>
`"${String(identifier).replaceAll('"', '""')}"`;
@@ -0,0 +1,81 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { connect, type Database } from '@tursodatabase/database';
import { isFullAdministrator } from '../src/lib/server/auth/bootstrap';
const run = promisify(execFile);
let directory: string | undefined;
let database: Database | undefined;
afterEach(async () => {
await database?.close();
if (directory) await rm(directory, { recursive: true, force: true });
database = undefined;
directory = undefined;
});
describe('legacy administrator migration', () => {
it('moves legacy administrator rows to role-admin and is repeatable', async () => {
directory = await mkdtemp(join(tmpdir(), 'shd-admin-migration-'));
const databasePath = join(directory, 'database.db');
database = await connect(databasePath);
await database.exec(await readFile('database/schema.sql', 'utf8'));
await (
await database.prepare(
"INSERT INTO users(id,username,role,created_at) VALUES('legacy-admin','legacy','admin',?)",
)
).run(new Date().toISOString());
await database.close();
database = undefined;
const environment = {
...process.env,
TDR_DATABASE_PATH: databasePath,
SKIP_DEFAULT_USERS: 'true',
};
await run(process.execPath, ['scripts/db-migrate.mjs'], {
cwd: process.cwd(),
env: environment,
});
database = await connect(databasePath);
let user = await (
await database.prepare('SELECT role FROM users WHERE id=?')
).get('legacy-admin');
let relationship = await (
await database.prepare(
'SELECT role_id FROM user_roles WHERE user_id=? AND role_id=?',
)
).get('legacy-admin', 'role-admin');
expect(user.role).toBe('user');
expect(relationship.role_id).toBe('role-admin');
await database.close();
database = undefined;
await run(process.execPath, ['scripts/db-migrate.mjs'], {
cwd: process.cwd(),
env: environment,
});
database = await connect(databasePath);
user = await (
await database.prepare('SELECT role FROM users WHERE id=?')
).get('legacy-admin');
relationship = await (
await database.prepare(
'SELECT COUNT(*) AS count FROM user_roles WHERE user_id=? AND role_id=?',
)
).get('legacy-admin', 'role-admin');
expect(user.role).toBe('user');
expect(Number(relationship.count)).toBe(1);
expect(await isFullAdministrator(database, 'legacy-admin')).toBe(true);
await (
await database.prepare(
'DELETE FROM user_roles WHERE user_id=? AND role_id=?',
)
).run('legacy-admin', 'role-admin');
expect(await isFullAdministrator(database, 'legacy-admin')).toBe(false);
});
});
+59
View File
@@ -5,6 +5,7 @@ const APP_REQUIRED = [
'APP_ORIGIN',
'ORIGIN',
'PASSKEY_RP_ID',
'MFA_TOTP_ENCRYPTION_KEYS',
'SCHEDULER_TOKEN',
'TDR_DATABASE_PATH',
'TDR_AVATAR_STORAGE_PATH',
@@ -48,6 +49,7 @@ export function validateRuntimeEnvironment(mode, environment) {
requireValues(APP_REQUIRED);
validateSchedulerToken(get('SCHEDULER_TOKEN'), errors);
validateMfaTotpEncryptionKeys(get('MFA_TOTP_ENCRYPTION_KEYS'), errors);
const appOrigin = validateOrigin('APP_ORIGIN', get('APP_ORIGIN'), errors);
const adapterOrigin = validateOrigin('ORIGIN', get('ORIGIN'), errors);
@@ -230,3 +232,60 @@ function validateSchedulerToken(value, errors) {
);
}
}
/**
* Validate the app-only TOTP encryption keyring without including configured
* values in an error. The first entry is the active write key.
*
* @param {string} value
* @param {string[]} errors
*/
function validateMfaTotpEncryptionKeys(value, errors) {
if (!value) return;
const entries = value.split(',').map((entry) => entry.trim());
if (
entries.length < 1 ||
entries.length > 3 ||
entries.some((entry) => !entry)
) {
errors.push(
'MFA_TOTP_ENCRYPTION_KEYS must contain 1 through 3 key entries.',
);
return;
}
const ids = new Set();
const keys = new Set();
for (const entry of entries) {
const separator = entry.indexOf(':');
const keyId = separator > 0 ? entry.slice(0, separator) : '';
const encodedKey = separator > 0 ? entry.slice(separator + 1) : '';
if (
!/^[A-Za-z0-9_-]{1,64}$/.test(keyId) ||
!/^[A-Za-z0-9_-]+$/.test(encodedKey) ||
encodedKey.length !== 43
) {
errors.push(
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
);
continue;
}
let decoded;
try {
decoded = Buffer.from(encodedKey, 'base64url');
} catch {
decoded = Buffer.alloc(0);
}
if (decoded.length !== 32 || decoded.toString('base64url') !== encodedKey) {
errors.push(
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
);
continue;
}
if (ids.has(keyId) || keys.has(encodedKey)) {
errors.push('MFA_TOTP_ENCRYPTION_KEYS must not repeat key IDs or keys.');
}
ids.add(keyId);
keys.add(encodedKey);
}
}
+23
View File
@@ -5,6 +5,8 @@ const validAppEnvironment = {
APP_ORIGIN: 'https://stage.shd.build',
ORIGIN: 'https://stage.shd.build',
PASSKEY_RP_ID: 'shd.build',
MFA_TOTP_ENCRYPTION_KEYS:
'primary-2026:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
@@ -21,6 +23,7 @@ describe('validateRuntimeEnvironment', () => {
'APP_ORIGIN is required.',
'ORIGIN is required.',
'PASSKEY_RP_ID is required.',
'MFA_TOTP_ENCRYPTION_KEYS is required.',
'SCHEDULER_TOKEN is required.',
'TDR_DATABASE_PATH is required.',
'TDR_AVATAR_STORAGE_PATH is required.',
@@ -158,4 +161,24 @@ describe('validateRuntimeEnvironment', () => {
validateRuntimeEnvironment('worker' as 'app', validAppEnvironment),
).toThrow('Unsupported runtime mode: worker');
});
it('validates the MFA encryption keyring without echoing secrets', () => {
expect(
validateRuntimeEnvironment('app', {
...validAppEnvironment,
MFA_TOTP_ENCRYPTION_KEYS:
'one:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,one:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
}),
).toEqual(['MFA_TOTP_ENCRYPTION_KEYS must not repeat key IDs or keys.']);
const errors = validateRuntimeEnvironment('app', {
...validAppEnvironment,
MFA_TOTP_ENCRYPTION_KEYS:
'primary:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
});
expect(errors).toEqual([
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
]);
expect(errors.join('\n')).not.toContain('primary:');
});
});
+1
View File
@@ -7,6 +7,7 @@ import { validateRuntimeEnvironment } from './runtime-environment.js';
const APP_SECRET_NAMES = new Set([
'APP_ORIGIN',
'MFA_TOTP_ENCRYPTION_KEYS',
'DISCORD_CLIENT_ID',
'DISCORD_CLIENT_SECRET',
'DISCORD_REDIRECT_URI',
+296 -30
View File
@@ -9006,6 +9006,47 @@ a.secondary:active {
padding: 0 9px !important;
font-size: var(--shd-type-telemetry) !important;
}
.builder-radio-toggle {
width: 100%;
min-height: 48px;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
margin-bottom: 5px;
padding: 6px 8px;
border: 1px solid var(--shd-border);
background: var(--shd-builder-canvas);
color: var(--shd-text);
text-align: left;
}
.builder-radio-toggle > img,
.builder-radio-toggle > i {
width: 32px;
height: 32px;
object-fit: contain;
}
.builder-radio-toggle > span {
min-width: 0;
display: grid;
gap: 2px;
}
.builder-radio-toggle b,
.builder-radio-toggle small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.builder-radio-toggle em {
color: var(--shd-orange);
font-size: var(--shd-type-telemetry);
font-style: normal;
font-weight: 900;
}
.builder-radio-toggle:hover,
.builder-radio-toggle:focus-visible {
border-color: var(--shd-orange);
}
.builder-radio-grid > div {
display: grid;
grid-template-columns: repeat(var(--builder-radio-columns), minmax(0, 1fr));
@@ -9131,6 +9172,40 @@ a.secondary:active {
gap: 3px;
margin-top: 6px;
}
.builder-talent-slot {
min-width: 0;
padding-top: 8px;
border-top: 1px solid var(--shd-border-soft);
}
.builder-talent-slot:first-child {
padding-top: 0;
border-top: 0;
}
.builder-exotic-talent > div:first-child {
min-height: 48px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid var(--shd-border);
background: var(--shd-builder-canvas);
}
.builder-exotic-talent > div:first-child > .item-icon {
width: 36px;
height: 36px;
}
.builder-exotic-talent > div:first-child > span {
min-width: 0;
display: grid;
gap: 2px;
}
.builder-exotic-talent small {
color: var(--shd-orange);
font-size: var(--shd-type-telemetry);
font-weight: 900;
letter-spacing: 0.07em;
}
.builder-rolls.required > div {
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.65fr);
}
@@ -9178,7 +9253,7 @@ a.secondary:active {
}
.builder-core-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 7px;
}
.builder-range-grid.weapon-damage {
@@ -9278,6 +9353,8 @@ a.secondary:active {
}
.loadout-slots > header,
.summary-section > header {
height: auto;
min-height: 36px;
display: flex;
justify-content: space-between;
align-items: center;
@@ -9356,6 +9433,136 @@ a.secondary:active {
min-height: 680px;
padding: 12px;
border-top: 2px solid var(--shd-orange);
container-type: inline-size;
}
.builder-editor-columns,
.builder-specialization-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 10px;
}
.builder-editor-lane {
min-width: 0;
align-content: start;
}
.builder-editor-lane + .builder-editor-lane {
padding-left: 16px;
border-left: 1px solid var(--shd-border);
}
.builder-editor-lane > :first-child {
margin-top: 0;
}
.builder-editor-lane .builder-weapon-controls {
grid-template-columns: minmax(180px, 1fr) minmax(120px, 0.65fr);
}
.builder-editor-lane .builder-equipment-controls {
grid-template-columns: 1fr;
}
.builder-editor-lane
.builder-equipment-controls
> .builder-radio-grid:last-child:nth-child(3) {
grid-column: auto;
}
.builder-editor-lane .builder-weapon-controls .builder-fixed {
grid-column: 1/-1;
}
.builder-editor-lane .builder-range-grid {
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
}
.builder-editor-lane .range-value-inputs {
grid-template-columns: minmax(0, 1fr) 72px;
}
.builder-editor-lane .builder-rolls.required > div,
.builder-editor-lane .builder-chip-rolls > div {
grid-template-columns: minmax(0, 1fr) minmax(150px, 0.72fr);
}
.builder-specialization-layout .builder-ability-chip-panels {
grid-template-columns: 1fr;
}
.builder-focus-abilities {
display: grid;
gap: var(--shd-gap-xs);
}
.builder-focus-abilities > button {
min-width: 0;
min-height: 58px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: var(--shd-gap-sm);
padding: var(--shd-gap-sm);
border: 1px solid var(--shd-border);
background: var(--shd-builder-canvas);
color: var(--shd-text);
text-align: left;
}
.builder-focus-abilities > button:hover,
.builder-focus-abilities > button:focus-visible {
border-color: var(--shd-orange);
}
.builder-focus-abilities img {
width: 38px;
height: 38px;
}
.builder-focus-abilities .builder-ability-icon {
width: 34px;
height: 34px;
}
.builder-focus-abilities span {
min-width: 0;
display: grid;
gap: 2px;
}
.builder-focus-abilities small,
.builder-focus-abilities b {
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
letter-spacing: 0.6px;
}
.builder-focus-abilities strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.builder-focus-abilities b {
white-space: nowrap;
}
.builder-specialization-layout .builder-specialization-identity {
grid-template-columns: auto minmax(0, 1fr);
}
.builder-specialization-layout .builder-specialization-identity > b {
grid-column: 1/-1;
width: max-content;
}
.builder-specialization-identity .focus-composite-icon {
width: 40px;
height: 40px;
flex: 0 0 40px;
}
.builder-specialization-identity .focus-composite-icon > .item-icon {
width: 36px;
height: 36px;
}
.builder-specialization-identity .focus-composite-icon > .game-icon {
right: -1px;
bottom: -1px;
width: 17px;
height: 17px;
padding: 2px;
}
@container (max-width: 700px) {
.builder-editor-columns,
.builder-specialization-layout {
grid-template-columns: 1fr;
gap: 12px;
}
.builder-editor-lane + .builder-editor-lane {
padding-top: 12px;
padding-left: 0;
border-top: 1px solid var(--shd-border);
border-left: 0;
}
}
.loadout-editor-head {
display: flex;
@@ -9688,11 +9895,12 @@ a.secondary:active {
}
.summary-core {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
border-top: 1px solid #303536;
border-bottom: 1px solid #303536;
}
.summary-core > div {
min-width: 0;
display: flex;
align-items: center;
gap: 5px;
@@ -9707,13 +9915,18 @@ a.secondary:active {
height: 22px;
}
.summary-core span {
min-width: 0;
display: grid;
color: #687072;
font-size: var(--shd-type-telemetry);
overflow-wrap: anywhere;
}
.summary-core b {
min-width: 0;
overflow: hidden;
color: #d6d9da;
font-size: var(--shd-type-telemetry);
text-overflow: ellipsis;
}
.summary-scroll {
max-height: calc(100vh - 340px);
@@ -9726,14 +9939,15 @@ a.secondary:active {
}
.summary-stat,
.summary-bonus {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(70px, auto);
gap: 8px;
padding: 8px 10px;
border-top: 1px solid #252a2b;
}
.summary-stat > span,
.summary-bonus > span {
min-width: 0;
display: grid;
gap: 3px;
font-size: var(--shd-type-telemetry);
@@ -9744,10 +9958,14 @@ a.secondary:active {
font-size: var(--shd-type-telemetry);
}
.summary-stat > b {
min-width: 0;
overflow-wrap: anywhere;
color: #d9dbdc;
font-size: var(--shd-type-telemetry);
}
.summary-bonus em {
min-width: 0;
overflow-wrap: anywhere;
color: var(--shd-success);
font-size: var(--shd-type-telemetry);
font-style: normal;
@@ -12003,10 +12221,7 @@ a.secondary:active {
}
.loadout-workspace {
min-width: 0;
grid-template-columns: minmax(300px, 350px) minmax(480px, 1fr) minmax(
290px,
340px
);
grid-template-columns: minmax(300px, 350px) minmax(0, 1fr);
gap: 6px;
}
.loadout-workspace > *,
@@ -12040,8 +12255,20 @@ a.secondary:active {
}
.loadout-summary {
min-width: 0;
position: static;
grid-column: 1/3;
max-height: none;
overflow: visible;
background: var(--shd-surface);
}
.loadout-summary .summary-scroll {
max-height: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
}
.builder-editor-lane .builder-radio-grid:not(.icon-only) > div {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.builder-picker-current > * {
min-width: 0;
}
@@ -12070,11 +12297,11 @@ a.secondary:active {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.builder-specialization-identity.ability-identity {
grid-template-columns: 54px minmax(0, 1fr) auto;
grid-template-columns: 46px minmax(0, 1fr) auto;
}
.builder-specialization-identity.ability-identity > .item-icon {
width: 50px;
height: 50px;
width: 42px;
height: 42px;
}
.shared-build-guide {
margin-top: 10px;
@@ -12118,11 +12345,13 @@ a.secondary:active {
.loadout-summary {
position: static;
grid-column: 1/3;
max-height: none;
overflow: visible;
}
.summary-scroll {
max-height: none;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
}
}
@media (max-width: 1050px) {
@@ -12699,10 +12928,9 @@ a.secondary:active {
}
.mod-secondary-pool-option {
display: grid;
grid-template-columns: 30px minmax(220px, 0.9fr) repeat(
3,
minmax(285px, 1fr)
) 30px;
grid-template-columns:
30px minmax(220px, 0.9fr) repeat(3, minmax(285px, 1fr))
30px;
align-items: stretch;
gap: 6px;
padding: 6px;
@@ -12814,10 +13042,9 @@ a.secondary:active {
}
.skill-chip-profile-option.selectable {
grid-template-columns: 32px minmax(210px, 1.2fr) repeat(
3,
minmax(130px, 1fr)
) 34px;
grid-template-columns:
32px minmax(210px, 1.2fr) repeat(3, minmax(130px, 1fr))
34px;
}
.skill-chip-profile-option > div:first-child,
@@ -15345,6 +15572,17 @@ a.secondary:active {
border-left: 2px solid var(--shd-info);
background: var(--shd-model-surface);
}
.specialization-skill-chip-sharing {
display: grid;
gap: var(--shd-gap-sm);
margin-top: var(--shd-gap-md);
padding: var(--shd-gap-md);
border: 1px solid var(--shd-border);
background: var(--shd-surface);
}
.specialization-skill-chip-sharing .assignment-picker {
margin: 0;
}
.specialization-attack-profile > .segmented-choice {
margin: 0;
}
@@ -15494,18 +15732,23 @@ a.secondary:active {
.builder-ability-chip-panels > section {
min-width: 0;
display: grid;
grid-template-rows: auto minmax(48px, auto) auto;
grid-template-rows: auto auto;
gap: var(--shd-gap-sm);
padding: var(--shd-gap-sm);
border: 1px solid var(--shd-border);
background: var(--shd-surface);
}
.builder-ability-chip-panels header {
height: auto;
position: static;
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--shd-gap-sm);
align-items: center;
padding: 0;
border: 0;
background: transparent;
}
.builder-ability-chip-panels header img {
width: 38px;
@@ -15528,13 +15771,6 @@ a.secondary:active {
.builder-ability-chip-panels header b {
white-space: nowrap;
}
.builder-ability-chip-panels > section > p {
min-width: 0;
margin: 0;
color: var(--shd-text-muted);
font-size: var(--shd-type-supporting);
overflow-wrap: anywhere;
}
.builder-ability-chip-panels > section > div {
min-width: 0;
display: grid;
@@ -15544,7 +15780,7 @@ a.secondary:active {
min-width: 0;
min-height: 54px;
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
grid-template-columns: 36px minmax(0, 1fr);
grid-template-rows: auto auto;
gap: 1px 8px;
padding: 7px;
@@ -15558,8 +15794,13 @@ a.secondary:active {
.builder-ability-chip-panels > section > div > button:focus-visible {
border-color: var(--shd-orange);
}
.builder-ability-chip-panels button small {
.builder-ability-chip-panels button > .builder-chip-icon {
grid-row: 1/3;
width: 32px;
height: 32px;
object-fit: contain;
}
.builder-ability-chip-panels button small {
color: var(--shd-orange);
font-size: var(--shd-type-telemetry);
font-weight: 900;
@@ -15575,6 +15816,28 @@ a.secondary:active {
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
}
.builder-ability-chip-panels button > span {
display: grid;
gap: 2px;
overflow: hidden;
white-space: normal;
}
.builder-ability-chip-panels button > span > strong,
.builder-ability-chip-panels button > span > em {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.builder-ability-chip-panels button em {
display: block;
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
font-style: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.builder-specialization-telemetry {
display: flex;
flex-wrap: wrap;
@@ -15600,6 +15863,9 @@ a.secondary:active {
background: var(--shd-model-surface);
}
.builder-focus-tree > section > header {
height: auto;
min-height: 32px;
position: static;
display: flex;
justify-content: space-between;
padding: 6px 8px;
+65
View File
@@ -2,6 +2,15 @@ import { json, type Handle, type HandleServerError } from '@sveltejs/kit';
import { stringify } from 'devalue';
import { randomUUID } from 'node:crypto';
import { getSessionUser } from '$lib/server/auth/session';
import {
hasActiveTotpFactor,
isFullAdministrator,
} from '$lib/server/auth/bootstrap';
import {
createAuthenticationTransaction,
getAuthenticationTransaction,
} from '$lib/server/auth/authentication-transactions';
import { getDatabase } from '$lib/server/database';
import {
getSiteAccessBlock,
getSiteAccessSettings,
@@ -49,6 +58,62 @@ export const handle: Handle = async ({ event, resolve }) => {
{ status: 404 },
);
}
const isIsacRoute =
event.url.pathname === '/isac' || event.url.pathname.startsWith('/isac/');
if (sessionUser && isIsacRoute) {
const database = await getDatabase();
if (await isFullAdministrator(database, sessionUser.id)) {
const hasFactor = await hasActiveTotpFactor(database, sessionUser.id);
const mutation = !['GET', 'HEAD'].includes(event.request.method);
if (!hasFactor) {
if (mutation)
return json(
{ error: 'Administrator MFA enrollment is required.' },
{ status: 403, headers: { 'cache-control': 'no-store' } },
);
return new Response(null, {
status: 303,
headers: {
location: '/account/security?required=admin',
'cache-control': 'no-store',
'referrer-policy': 'no-referrer',
},
});
}
if (sessionUser.aal !== 2) {
if (mutation)
return json(
{ error: 'AAL2 authentication is required.' },
{ status: 403, headers: { 'cache-control': 'no-store' } },
);
const returnPath = `${event.url.pathname}${event.url.search}`;
const existing = await getAuthenticationTransaction(
event.cookies,
database,
);
if (
!existing ||
existing.userId !== sessionUser.id ||
existing.kind !== 'step_up'
) {
await createAuthenticationTransaction(event.cookies, database, {
userId: sessionUser.id,
kind: 'step_up',
returnPath,
});
}
return new Response(null, {
status: 303,
headers: {
location: '/auth/two-factor',
'cache-control': 'no-store',
'referrer-policy': 'no-referrer',
},
});
}
}
}
if (event.request.method === 'POST') {
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
const module = match?.[1] as DataModule | undefined;
+2 -1
View File
@@ -259,7 +259,7 @@ describe('loadout mechanic timeline', () => {
).toEqual([1, 2]);
});
it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => {
it('falls back to a documented Tier I mechanic when exotic Tier II is absent', () => {
const persistedRule = {
rule: {
id: 'tier-i-only',
@@ -327,6 +327,7 @@ describe('loadout mechanic timeline', () => {
const adapted = equippedConditionalProgram({
key: 'weapon:0:exotic-talent',
tier: 2,
isExotic: true,
rule: persistedRule,
});
+1 -1
View File
@@ -283,7 +283,7 @@ export function equippedConditionalProgram(
: 'base';
return (
conditionalProgram(equipped.rule, variant) ??
(equipmentTalent && variant === 'II'
(equipmentTalent && equipped.isExotic && variant === 'II'
? conditionalProgram(equipped.rule, 'I')
: null)
);
+157 -9
View File
@@ -207,7 +207,7 @@ describe('summarizeLoadout', () => {
});
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
key: 'chips:chip-set',
key: 'chips:chip-set:ability',
tier: 1,
abilityId: 'ability',
description: 'Gain 25% Skill Damage.',
@@ -217,6 +217,7 @@ describe('summarizeLoadout', () => {
it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => {
const draft = createDraft();
draft.specialization.focusId = 'focus-1';
draft.chips[0] = {
setId: 'chip-set',
quality: 'Superior',
@@ -239,11 +240,24 @@ describe('summarizeLoadout', () => {
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability-1',
specialization_ability_id: 'ability-2',
compatibleAbilityIds: ['ability-2', 'ability-1'],
twoPiece: null,
talent: null,
},
],
specializations: [
{
id: 'specialization-1',
focuses: [
{
id: 'focus-1',
abilities: [{ id: 'ability-1' }],
nodes: [],
},
],
},
],
}),
);
@@ -272,6 +286,50 @@ describe('summarizeLoadout', () => {
]);
});
it('ignores a shared Skill Chip after its ability link is removed', () => {
const draft = createDraft();
draft.specialization.focusId = 'focus-1';
draft.chips[0] = {
setId: 'chip-set',
quality: 'Standard',
primary: { statId: 'skill-duration', value: 12 },
secondary: [],
};
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability-2',
compatibleAbilityIds: ['ability-2'],
twoPiece: null,
talent: null,
},
],
specializations: [
{
id: 'specialization-1',
focuses: [
{
id: 'focus-1',
abilities: [{ id: 'ability-1' }],
nodes: [],
},
],
},
],
}),
);
expect(summary.abilityStats).toEqual([]);
});
it.each([
[0, 10],
[1, 20],
@@ -425,11 +483,58 @@ describe('summarizeLoadout', () => {
expect(stat(summary, 'mechanic')).toBeUndefined();
});
it('renders the selected Talent tier without applying its mechanic outcomes to stats', () => {
it('uses a gear Talent tier independently from the gear tier', () => {
const draft = createDraft();
draft.gear.Holster.itemId = 'gear';
draft.gear.Holster.talentId = 'talent';
Object.assign(draft.gear.Holster, { talentTier: 2 });
const summary = summarizeLoadout(
draft,
catalog({
attributes: [attribute('bonus', 'Bonus', { unit: '%' })],
gear: [{ id: 'gear', tier: 0, base_armor_min: null }],
talents: [
{
id: 'talent',
name: 'Reinforced',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'bonus', value: 10 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'bonus', value: 20 }],
},
],
},
],
}),
);
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
id: 'talent',
tier: 2,
tierLabel: 'TIER II',
description: 'Gain 20%.',
}),
]);
});
it('uses each weapon Talent tier independently without applying mechanic outcomes to stats', () => {
const draft = createDraft();
draft.weapons[0].itemId = 'weapon';
draft.weapons[0].tier = 2;
draft.weapons[0].talent1Id = 'talent';
draft.weapons[0].talent1Id = 'talent-one';
draft.weapons[0].talent2Id = 'talent-two';
Object.assign(draft.weapons[0], {
talent1Tier: 1,
talent2Tier: 2,
exoticTalentTier: 1,
});
const rule = {
clauses: [
@@ -455,13 +560,13 @@ describe('summarizeLoadout', () => {
{
id: 'weapon',
name: 'Test Weapon',
exotic_talent_id: null,
exotic_talent_id: 'exotic-talent',
baseBonuses: [],
},
],
talents: [
{
id: 'talent',
id: 'talent-one',
name: 'Measured',
template: 'Gain {1} for {2}.',
tiers: [
@@ -482,17 +587,60 @@ describe('summarizeLoadout', () => {
],
rule,
},
{
id: 'talent-two',
name: 'Calibrated',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'talent-stat', value: 30 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'talent-stat', value: 40 }],
},
],
},
{
id: 'exotic-talent',
name: 'Exotic Talent',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'talent-stat', value: 50 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'talent-stat', value: 60 }],
},
],
},
],
}),
);
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
id: 'talent',
tier: 2,
description: 'Gain 20% for 3 Shots.',
id: 'talent-one',
tier: 1,
tierLabel: 'TIER I',
description: 'Gain 10% for 2 Shots.',
rule,
}),
expect.objectContaining({
id: 'talent-two',
tier: 2,
tierLabel: 'TIER II',
description: 'Gain 40%.',
}),
expect.objectContaining({
id: 'exotic-talent',
tier: 1,
tierLabel: 'TIER I',
description: 'Gain 50%.',
}),
]);
expect(stat(summary, 'talent-stat')).toBeUndefined();
});
+71 -24
View File
@@ -1,4 +1,9 @@
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
import {
gearSlots,
scaled,
type LoadoutDraft,
type TalentTier,
} from '$lib/builder/loadout';
export type LoadoutCatalog = {
attributes: any[];
@@ -100,18 +105,21 @@ function unitValue(catalog: LoadoutCatalog, value: unknown, statId: string) {
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
}
function talentTier(catalog: LoadoutCatalog, talentId: string, tier: number) {
function talentTier(
catalog: LoadoutCatalog,
talentId: string,
tier: TalentTier,
) {
const item = catalog.talents.find((entry) => entry.id === talentId);
return (
item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ??
item?.tiers[0]
return item?.tiers.find(
(entry: any) => entry.tier === (tier === 2 ? 'II' : 'I'),
);
}
function talentDescription(
catalog: LoadoutCatalog,
talentId: string,
tier: number,
tier: TalentTier,
) {
const item = catalog.talents.find((entry) => entry.id === talentId);
const configured = talentTier(catalog, talentId, tier);
@@ -255,12 +263,14 @@ export function summarizeLoadout(
rule:
catalog.talents.find((entry) => entry.id === selection.talentId)
?.rule ?? null,
tier: Number(item.tier),
tier: selection.talentTier,
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
isExotic: false,
source: slot,
description: talentDescription(
catalog,
selection.talentId,
Number(item.tier),
selection.talentTier,
),
});
}
@@ -305,27 +315,48 @@ export function summarizeLoadout(
roll.value,
`${index ? 'Secondary' : 'Primary'} weapon roll`,
);
for (const talentId of [
selection.talent1Id,
selection.talent2Id,
item.exotic_talent_id,
].filter(Boolean)) {
const equippedWeaponTalents = [
{
id: selection.talent1Id,
tier: selection.talent1Tier,
isExotic: false,
},
{
id: selection.talent2Id,
tier: selection.talent2Tier,
isExotic: false,
},
{
id: item.exotic_talent_id,
tier: selection.exoticTalentTier,
isExotic: true,
},
].filter((entry) => entry.id);
for (const equippedTalent of equippedWeaponTalents) {
equippedTalents.push({
key: `weapon:${index}:${talentId}`,
id: talentId,
key: `weapon:${index}:${equippedTalent.id}`,
id: equippedTalent.id,
rule:
catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
tier: selection.tier,
catalog.talents.find((entry) => entry.id === equippedTalent.id)
?.rule ?? null,
tier: equippedTalent.tier,
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
isExotic: equippedTalent.isExotic,
source: index ? 'Secondary weapon' : 'Primary weapon',
description: talentDescription(catalog, talentId, selection.tier),
description: talentDescription(
catalog,
equippedTalent.id,
equippedTalent.tier,
),
});
}
}
const chipGroups = new Map<
string,
{ count: number; highEnd: number; item: any }
{ count: number; highEnd: number; item: any; abilityId: string }
>();
const focus = selectedFocus(draft, catalog);
const validChipRoll = (
kind: 'primaryOptions' | 'secondaryOptions',
quality: string,
@@ -345,9 +376,19 @@ export function summarizeLoadout(
const selection = draft.chips[index];
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
if (!item) continue;
const slotAbilityId = String(
focus?.abilities?.[Math.floor(index / 3)]?.id ?? '',
);
const compatibleAbilityIds = item.compatibleAbilityIds ?? [
String(item.specialization_ability_id ?? ''),
];
if (slotAbilityId && !compatibleAbilityIds.includes(slotAbilityId))
continue;
const abilityId =
slotAbilityId || String(item.specialization_ability_id ?? '');
if (validChipRoll('primaryOptions', selection.quality, selection.primary))
addAbilityStat(
String(item.specialization_ability_id ?? ''),
abilityId,
selection.primary.statId,
selection.primary.value,
index,
@@ -356,10 +397,16 @@ export function summarizeLoadout(
for (const roll of selection.secondary)
if (validChipRoll('secondaryOptions', selection.quality, roll))
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
const groupKey = `${item.id}:${abilityId}`;
const group = chipGroups.get(groupKey) ?? {
count: 0,
highEnd: 0,
item,
abilityId,
};
group.count += 1;
if (selection.quality === 'High-end') group.highEnd += 1;
chipGroups.set(item.id, group);
chipGroups.set(groupKey, group);
}
for (const group of chipGroups.values()) {
if (group.count >= 2 && group.item.twoPiece) {
@@ -384,12 +431,12 @@ export function summarizeLoadout(
}
if (group.count === 3 && group.item.talent) {
equippedTalents.push({
key: `chips:${group.item.id}`,
key: `chips:${group.item.id}:${group.abilityId}`,
id: '',
custom: group.item.talent,
rule: group.item.talent.rule ?? null,
tier: group.highEnd,
abilityId: group.item.specialization_ability_id ?? null,
abilityId: group.abilityId || null,
source: '3 Skill Chips',
description: group.item.talent.description_template.replace(
/\{(\d+)\}/g,
+125 -2
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
createDraft,
migrateLoadoutDraftV5,
parseLoadoutDraft,
skillChipSecondaryCapacity,
type SkillChipQuality,
@@ -8,13 +9,40 @@ import {
const copyDraft = () => structuredClone(createDraft());
const talentTierFields = [
[
'gear talentTier',
(draft: ReturnType<typeof copyDraft>) =>
draft.gear.Holster as unknown as Record<string, unknown>,
'talentTier',
],
[
'weapon talent1Tier',
(draft: ReturnType<typeof copyDraft>) =>
draft.weapons[0] as unknown as Record<string, unknown>,
'talent1Tier',
],
[
'weapon talent2Tier',
(draft: ReturnType<typeof copyDraft>) =>
draft.weapons[0] as unknown as Record<string, unknown>,
'talent2Tier',
],
[
'weapon exoticTalentTier',
(draft: ReturnType<typeof copyDraft>) =>
draft.weapons[0] as unknown as Record<string, unknown>,
'exoticTalentTier',
],
] as const;
describe('parseLoadoutDraft', () => {
it('accepts a current draft and returns an independent clone', () => {
const draft = createDraft();
const parsed = parseLoadoutDraft(draft);
expect(parsed).toEqual(draft);
expect(parsed?.version).toBe(5);
expect(parsed?.version).toBe(6);
expect(parsed?.chips[0]).toEqual({
setId: '',
quality: 'Standard',
@@ -27,6 +55,37 @@ describe('parseLoadoutDraft', () => {
expect(draft.gear.Holster.level).toBe(50);
});
it('defaults every equipment Talent tier to Tier I', () => {
const draft = createDraft();
expect(
Object.values(draft.gear).map(
(selection) =>
(selection as unknown as Record<string, unknown>).talentTier,
),
).toEqual(Array(6).fill(1));
expect(
draft.weapons.map((selection) => {
const weapon = selection as unknown as Record<string, unknown>;
return [
weapon.talent1Tier,
weapon.talent2Tier,
weapon.exoticTalentTier,
];
}),
).toEqual([
[1, 1, 1],
[1, 1, 1],
]);
});
it('accepts Tier II for every equipment Talent selection', () => {
const draft = copyDraft();
for (const [, select, key] of talentTierFields) select(draft)[key] = 2;
expect(parseLoadoutDraft(draft)).toEqual(draft);
});
it('rejects the retired version 1 three-chip shape', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 1;
@@ -42,13 +101,77 @@ describe('parseLoadoutDraft', () => {
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('rejects a version 5 draft that does not contain exactly nine chips', () => {
it('rejects the retired version 5 schema', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 5;
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('migrates version 5 equipment-derived Talent tiers once', () => {
const legacy = copyDraft() as unknown as Record<string, any>;
legacy.version = 5;
for (const selection of Object.values(legacy.gear) as Record<
string,
any
>[]) {
delete selection.talentTier;
}
for (const selection of legacy.weapons as Record<string, any>[]) {
delete selection.talent1Tier;
delete selection.talent2Tier;
delete selection.exoticTalentTier;
}
legacy.gear.Holster.itemId = 'tier-two-gear';
legacy.weapons[0].itemId = 'exotic-weapon';
legacy.weapons[0].tier = 2;
const migrated = migrateLoadoutDraftV5(legacy, {
gear: [{ id: 'tier-two-gear', tier: 2 }],
weapons: [{ id: 'exotic-weapon', exotic_talent_id: 'exotic-talent' }],
talents: [
{
id: 'exotic-talent',
tiers: [{ tier: 'I' }, { tier: 'II' }],
},
],
});
expect(migrated?.version).toBe(6);
expect(migrated?.gear.Holster.talentTier).toBe(2);
expect(migrated?.gear.Gloves.talentTier).toBe(1);
expect(migrated?.weapons[0]).toMatchObject({
talent1Tier: 2,
talent2Tier: 2,
exoticTalentTier: 2,
});
});
it('rejects a current draft that does not contain exactly nine chips', () => {
const draft = copyDraft();
draft.chips.pop();
expect(parseLoadoutDraft(draft)).toBeNull();
});
it.each(talentTierFields)('rejects a draft missing %s', (_, select, key) => {
const draft = copyDraft();
delete select(draft)[key];
expect(parseLoadoutDraft(draft)).toBeNull();
});
it.each(
talentTierFields.flatMap(([name, select, key]) =>
[0, 1.5, 3].map((value) => [name, value, select, key] as const),
),
)('rejects %s value %s', (_, value, select, key) => {
const draft = copyDraft();
select(draft)[key] = value;
expect(parseLoadoutDraft(draft)).toBeNull();
});
it.each([
['Standard', 0],
['Specialized', 1],
+55 -3
View File
@@ -1,4 +1,5 @@
export type Roll = { statId: string; value: number };
export type TalentTier = 1 | 2;
export const skillChipQualities = [
'Standard',
'Specialized',
@@ -17,6 +18,7 @@ export type GearSelection = {
level: number;
brandId: string;
talentId: string;
talentTier: TalentTier;
health: number;
armor: number;
firepower: number;
@@ -34,7 +36,10 @@ export type WeaponSelection = {
toughness: number;
engineering: number;
talent1Id: string;
talent1Tier: TalentTier;
talent2Id: string;
talent2Tier: TalentTier;
exoticTalentTier: TalentTier;
rolls: [Roll, Roll];
};
export type ChipSelection = {
@@ -81,7 +86,7 @@ export type SpecializationSelection = {
nodeRanks: Record<string, number>;
};
export type LoadoutDraft = {
version: 5;
version: 6;
buildName: string;
calculationMode: CalculationMode;
gear: Record<string, GearSelection>;
@@ -118,6 +123,7 @@ export const blankGear = (): GearSelection => ({
level: 50,
brandId: '',
talentId: '',
talentTier: 1,
health: 0,
armor: 0,
firepower: 0,
@@ -135,7 +141,10 @@ export const blankWeapon = (): WeaponSelection => ({
toughness: 0,
engineering: 0,
talent1Id: '',
talent1Tier: 1,
talent2Id: '',
talent2Tier: 1,
exoticTalentTier: 1,
rolls: [blankRoll(), blankRoll()],
});
export const blankChip = (): ChipSelection => ({
@@ -161,7 +170,7 @@ export const defaultCombatProfile = (): CombatProfile => ({
extraHealthActive: false,
});
export const createDraft = (): LoadoutDraft => ({
version: 5,
version: 6,
buildName: 'Untitled loadout',
calculationMode: 'maximum',
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
@@ -187,6 +196,39 @@ export const createDraft = (): LoadoutDraft => ({
},
});
export function migrateLoadoutDraftV5(
value: unknown,
catalog: { gear: any[]; weapons: any[]; talents: any[] },
): LoadoutDraft | null {
if (!isRecord(value) || value.version !== 5) return null;
const migrated = structuredClone(value);
if (!isRecord(migrated.gear) || !Array.isArray(migrated.weapons)) return null;
for (const slot of gearSlots) {
const selection = migrated.gear[slot];
if (!isRecord(selection)) return null;
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
selection.talentTier = Number(item?.tier) >= 2 ? 2 : 1;
}
for (const selection of migrated.weapons) {
if (!isRecord(selection)) return null;
const inheritedTier: TalentTier = Number(selection.tier) >= 2 ? 2 : 1;
selection.talent1Tier = inheritedTier;
selection.talent2Tier = inheritedTier;
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
const exoticTalent = catalog.talents.find(
(entry) => entry.id === item?.exotic_talent_id,
);
selection.exoticTalentTier =
inheritedTier === 2 &&
exoticTalent?.tiers?.some((entry: any) => entry.tier === 'II')
? 2
: 1;
}
migrated.version = 6;
return parseLoadoutDraft(migrated);
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
@@ -194,6 +236,8 @@ const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
keys.every((key) => key in value);
const isFiniteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
const isTalentTier = (value: unknown): value is TalentTier =>
value === 1 || value === 2;
const isNumberRecord = (value: unknown) =>
isRecord(value) &&
Object.values(value).every(
@@ -213,6 +257,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
'level',
'brandId',
'talentId',
'talentTier',
'health',
'armor',
'firepower',
@@ -224,6 +269,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
['itemId', 'brandId', 'talentId'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talentTier) &&
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
@@ -241,12 +287,18 @@ const isWeaponSelection = (value: unknown): value is WeaponSelection =>
'toughness',
'engineering',
'talent1Id',
'talent1Tier',
'talent2Id',
'talent2Tier',
'exoticTalentTier',
'rolls',
]) &&
['itemId', 'talent1Id', 'talent2Id'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talent1Tier) &&
isTalentTier(value.talent2Tier) &&
isTalentTier(value.exoticTalentTier) &&
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
@@ -369,7 +421,7 @@ export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
'skillActivations',
'specialization',
]) ||
value.version !== 5 ||
value.version !== 6 ||
typeof value.buildName !== 'string' ||
!['maximum', 'custom'].includes(String(value.calculationMode))
)
+7 -5
View File
@@ -791,9 +791,9 @@ describe('conditional mechanic authoring payload', () => {
});
});
it('rejects invalid pre-event timing for current Health percentage references', () => {
const invalid = draft();
invalid.clauses[0].if = {
it('preserves pre-event timing for current Health percentage references', () => {
const value = draft();
value.clauses[0].if = {
kind: 'compare',
left: {
source: 'combat',
@@ -803,11 +803,13 @@ describe('conditional mechanic authoring payload', () => {
operator: 'lt',
right: 50,
};
const result = parseConditionalMechanicDraft(invalid, {
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(result.ok).toBe(false);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.draft.clauses[0].if).toEqual(value.clauses[0].if);
});
it('accepts compatible radius targeting and rejects incompatible scopes', () => {
+1 -2
View File
@@ -426,8 +426,7 @@ function isReference(value: unknown): value is ValueReference {
Boolean(value.path.trim()) &&
(value.timing === undefined ||
value.timing === 'pre_event' ||
value.timing === 'current') &&
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
value.timing === 'current')
);
case 'equipment':
return typeof value.path === 'string' && Boolean(value.path.trim());
@@ -850,7 +850,7 @@ describe('conditional-effect persistence adapter', () => {
left: {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current',
timing: 'pre_event',
},
operator: 'lt',
right: 25,
+1 -2
View File
@@ -216,8 +216,7 @@ function isValueReference(value: unknown) {
Boolean(value.path.trim()) &&
(value.timing === undefined ||
value.timing === 'pre_event' ||
value.timing === 'current') &&
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
value.timing === 'current')
);
}
if (value.source === 'attribute') {
+61 -18
View File
@@ -2834,13 +2834,13 @@ describe('mechanic validation and pseudo-script', () => {
]);
});
it('rejects pre-event timing for the derived current Health percentage', () => {
const invalid: MechanicProgram = {
id: 'invalid-health-percent',
name: 'Invalid Health Percent',
it('evaluates pre-event timing for the derived current Health percentage', () => {
const program: MechanicProgram = {
id: 'pre-event-health-percent',
name: 'Pre-event Health Percent',
clauses: [
{
id: 'invalid',
id: 'full-health-before-event',
when: 'tick',
if: {
kind: 'compare',
@@ -2849,25 +2849,32 @@ describe('mechanic validation and pseudo-script', () => {
path: 'currentHealthPercent',
timing: 'pre_event',
},
operator: 'lt',
right: 50,
operator: 'gte',
right: 100,
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'matched',
label: 'Matched',
phase: 'on_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
value: 1,
},
],
},
],
};
const result = validateMechanicProgram(invalid);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toEqual(
expect.arrayContaining([
expect.stringContaining(
'current Health percentage must use current timing',
),
]),
);
}
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
expect(
evaluateMechanicProgram(program, [{ kind: 'tick', at: 0 }], custom(1), {
maxHealth: 100,
currentHealth: 100,
}).outcomes.map((outcome) => outcome.outcomeId),
).toEqual(['matched']);
});
it('formats nested conditions and state operations deterministically', () => {
@@ -3104,6 +3111,42 @@ describe('mechanic validation and pseudo-script', () => {
expect(
maximum.outcomes.every((outcome) => outcome.probabilityRoll === 0),
).toBe(true);
const skillProgram: MechanicProgram = {
...program,
id: 'skill-hit-explosion',
clauses: program.clauses.map((clause) => ({
...clause,
when: 'skill_hit',
if: undefined,
})),
};
expect(validateMechanicProgram(skillProgram)).toMatchObject({ ok: true });
expect(
evaluateMechanicProgram(
skillProgram,
[
{
kind: 'skill_hit',
at: 1,
targetId: 'enemy-b',
areaTargetSample: {
center: 'event_target',
validTargets: [
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
],
},
},
],
custom(5),
{
maxHealth: 1000,
currentHealth: 1000,
attributes: { firepower: 100 },
},
).outcomes.map((outcome) => outcome.targetId),
).toEqual(['enemy-a', 'enemy-b']);
});
it('requires authoritative area evidence only when an area outcome activates', () => {
+13 -14
View File
@@ -97,6 +97,11 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<
description:
'The Agent deploys or activates a specialization ability. An assigned-ability deployment may supply its active duration.',
fields: [
{
path: 'values.abilityId',
label: 'Deployed ability ID',
type: 'string',
},
{
path: 'values.assignedAbilityDurationSeconds',
label: 'Assigned ability duration',
@@ -123,8 +128,9 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<
key: 'skill_hit',
label: 'Skill hit',
description:
'A deployed skill connects with a target. Critical and distance details belong in event values.',
'A deployed skill connects with a target. Critical and distance details belong in event values. An optional area-target sample lists valid entities by distance from the hit target.',
fields: [
{ path: 'values.abilityId', label: 'Source ability ID', type: 'string' },
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
{
path: 'values.assignedAbilityActive',
@@ -1892,15 +1898,8 @@ function validateReference(
}
if (reference.source === 'event' && !reference.path.trim())
errors.push(`${path} requires an event path.`);
if (reference.source === 'combat') {
if (!reference.path.trim()) errors.push(`${path} requires a combat path.`);
if (
reference.path === 'currentHealthPercent' &&
reference.timing === 'pre_event'
) {
errors.push(`${path} current Health percentage must use current timing.`);
}
}
if (reference.source === 'combat' && !reference.path.trim())
errors.push(`${path} requires a combat path.`);
if (reference.source === 'attribute' && !reference.attributeId.trim())
errors.push(`${path} requires an Attribute ID.`);
if (reference.source === 'equipment' && !reference.path.trim())
@@ -2067,7 +2066,7 @@ function validateMechanicEvents(events: MechanicEvent[]) {
if (sample === undefined) continue;
const samplePath = `Event ${index + 1} (${definition.label}) area-target sample`;
if (
event.kind !== 'weapon_hit' ||
!['weapon_hit', 'skill_hit'].includes(event.kind) ||
!objectValue(sample) ||
sample.center !== 'event_target' ||
!Array.isArray(sample.validTargets) ||
@@ -2075,7 +2074,7 @@ function validateMechanicEvents(events: MechanicEvent[]) {
!event.targetId.trim()
) {
errors.push(
`${samplePath} requires a weapon-hit event target and event-target-centered valid targets.`,
`${samplePath} requires a weapon-hit or skill-hit event target and event-target-centered valid targets.`,
);
continue;
}
@@ -2769,10 +2768,10 @@ export function validateMechanicProgram(
if (targetingError) errors.push(`${outcomePath} ${targetingError}`);
if (
outcome.targeting?.kind === 'event_target_area' &&
clause.when !== 'weapon_hit'
!['weapon_hit', 'skill_hit'].includes(clause.when)
) {
errors.push(
`${outcomePath} event-target area damage requires a weapon-hit clause.`,
`${outcomePath} event-target area damage requires a weapon-hit or skill-hit clause.`,
);
}
if (
+6 -2
View File
@@ -172,6 +172,10 @@
<a class="discord-link-button" class:interaction-pending={discordLinkPending} aria-busy={discordLinkPending} href="/auth/discord?mode=link" onclick={() => discordLinkPending = true}><img src="/discord-symbol.svg" alt="" aria-hidden="true"/><span>Link Discord</span></a>
{/if}
{/if}
<div class="security-summary" aria-label="Multi-factor authentication">
<div><p class="eyebrow">ACCOUNT HARDENING</p><h2>MULTI-FACTOR AUTHENTICATION</h2><small>{account.mfa?.enabled ? 'Authenticator active.' : 'Optional for standard accounts.'}</small></div>
<a class="secondary security-link" href="/account/security">REVIEW SECURITY →</a>
</div>
</div>
{:else}
<div class="account-panel" role="tabpanel">
@@ -183,6 +187,6 @@
</section>
<style>
.owner-account-control{width:100%;min-width:0;padding:12px}.current-avatar{display:grid;grid-template-columns:58px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.current-avatar>img{width:58px;height:58px;border:1px solid var(--shd-orange);border-radius:50%;object-fit:cover}.current-avatar>div{min-width:0;display:grid;gap:3px}.current-avatar small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.current-avatar strong{overflow:hidden;color:var(--shd-text);font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}.avatar-edit-trigger{min-height:40px;padding:0 10px}.avatar-editor{display:grid;gap:9px;margin-top:9px;padding-top:9px;border-top:1px solid var(--shd-border)}.account-section-heading{display:flex;align-items:start;justify-content:space-between;gap:8px}.account-section-heading h2{margin-bottom:0}.owner-account-control :global(.image-drop-input){grid-template-columns:58px minmax(0,1fr)}.owner-account-control :global(.image-upload-actions){grid-column:1/-1;grid-template-columns:1fr}.discord-link-button{width:100%;min-height:44px;display:flex;align-items:center;justify-content:center;gap:9px;border:1px solid #6571dc;background:#171a32;color:#fff}.discord-link-button img{width:22px}.provider-unlink-confirm{grid-column:1/-1;display:grid;gap:8px;margin-top:8px;padding-top:8px;border-top:1px solid var(--shd-border)}.provider-unlink-confirm>div{display:grid;grid-template-columns:1fr 1fr;gap:6px}.provider-unlink-confirm button{width:100%;min-height:40px}
@media(max-width:650px){.current-avatar{grid-template-columns:52px minmax(0,1fr)}.current-avatar>img{width:52px;height:52px}.avatar-edit-trigger{grid-column:1/-1;width:100%;min-height:44px}}
.owner-account-control{width:100%;min-width:0;padding:12px}.current-avatar{display:grid;grid-template-columns:58px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.current-avatar>img{width:58px;height:58px;border:1px solid var(--shd-orange);border-radius:50%;object-fit:cover}.current-avatar>div{min-width:0;display:grid;gap:3px}.current-avatar small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.current-avatar strong{overflow:hidden;color:var(--shd-text);font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}.avatar-edit-trigger{min-height:40px;padding:0 10px}.avatar-editor{display:grid;gap:9px;margin-top:9px;padding-top:9px;border-top:1px solid var(--shd-border)}.account-section-heading{display:flex;align-items:start;justify-content:space-between;gap:8px}.account-section-heading h2{margin-bottom:0}.owner-account-control :global(.image-drop-input){grid-template-columns:58px minmax(0,1fr)}.owner-account-control :global(.image-upload-actions){grid-column:1/-1;grid-template-columns:1fr}.discord-link-button{width:100%;min-height:44px;display:flex;align-items:center;justify-content:center;gap:9px;border:1px solid #6571dc;background:#171a32;color:#fff}.discord-link-button img{width:22px}.provider-unlink-confirm{grid-column:1/-1;display:grid;gap:8px;margin-top:8px;padding-top:8px;border-top:1px solid var(--shd-border)}.provider-unlink-confirm>div{display:grid;grid-template-columns:1fr 1fr;gap:6px}.provider-unlink-confirm button{width:100%;min-height:40px}.security-summary{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:18px;padding:14px 0;border-top:1px solid var(--shd-border)}.security-summary h2{margin:2px 0 4px}.security-summary small{color:var(--shd-text-muted)}.security-link{white-space:nowrap;text-decoration:none}
@media(max-width:650px){.current-avatar{grid-template-columns:52px minmax(0,1fr)}.current-avatar>img{width:52px;height:52px}.avatar-edit-trigger{grid-column:1/-1;width:100%;min-height:44px}.security-summary{align-items:stretch;flex-direction:column}.security-link{width:100%;text-align:center}}
</style>
+44 -24
View File
@@ -23,7 +23,9 @@
let coverUploading = $state(false);
let initialized = $state(false);
let lastContent = '';
let saveSequence = 0;
let saveTimer: number | undefined;
let saveInFlight = false;
let saveQueued = false;
const groupedTags = $derived.by(() => {
const groups: Record<string, ComposerTag[]> = {};
for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag);
@@ -35,6 +37,40 @@
coverImageId, coverAlt, coverCaption, version };
}
function scheduleSave() {
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
saveTimer = undefined;
void saveDraft();
}, 900);
}
async function saveDraft() {
if (saveInFlight || !saveQueued) return;
saveInFlight = true;
saveQueued = false;
const draft = { ...payload(), tagIds: [...selectedTags] };
try {
const response = await fetch('/api/articles/draft', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(draft)
});
const result = await response.json();
if (response.status === 409) {
saveState = 'conflict';
return;
}
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
version = result.version;
saveState = saveQueued ? 'saving' : 'saved';
} catch (error) {
saveState = 'error';
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
} finally {
saveInFlight = false;
if (saveQueued && saveState !== 'conflict') scheduleSave();
}
}
$effect(() => {
const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption });
if (!initialized) {
@@ -44,29 +80,13 @@
}
if (content === lastContent) return;
lastContent = content;
const sequence = ++saveSequence;
saveQueued = true;
saveState = 'saving';
const timer = window.setTimeout(async () => {
try {
const response = await fetch('/api/articles/draft', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload())
});
const result = await response.json();
if (sequence !== saveSequence) return;
if (response.status === 409) {
saveState = 'conflict';
return;
}
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
version = result.version;
saveState = 'saved';
} catch (error) {
if (sequence !== saveSequence) return;
saveState = 'error';
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
}
}, 900);
return () => window.clearTimeout(timer);
if (!saveInFlight) scheduleSave();
return () => {
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
saveTimer = undefined;
};
});
function toggleTag(id: string) {
@@ -115,7 +135,7 @@
<div class="article-publication-note"><p>{visibility === 'private' ? 'Only you can open this draft.' : visibility === 'unlisted' ? 'Anyone with the direct URL can read it.' : 'Listed in discovery and visible to everyone.'}</p>{#if data.article.pendingRevisionId}<strong>MODERATION REVIEW PENDING</strong>{/if}</div>
<PendingForm action="?/publish" class="article-publish-form">
<input type="hidden" name="articleId" value={data.article.id}/><input type="hidden" name="title" value={title}/><input type="hidden" name="deck" value={deck}/><input type="hidden" name="document" value={document}/><input type="hidden" name="tagIds" value={JSON.stringify(selectedTags)}/><input type="hidden" name="channel" value={channel}/><input type="hidden" name="coverImageId" value={coverImageId}/><input type="hidden" name="coverAlt" value={coverAlt}/><input type="hidden" name="coverCaption" value={coverCaption}/><input type="hidden" name="version" value={version}/><input type="hidden" name="visibility" value={visibility}/>
<button class="primary">{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
<button class="primary" disabled={saveState === 'saving' || coverUploading}>{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
</PendingForm>
</section>
+4 -4
View File
@@ -5,7 +5,7 @@
items,
value = $bindable(''),
label,
placeholder = 'Search available items…',
placeholder = 'Search…',
disabledIds = [],
onchange
}: {
@@ -27,19 +27,19 @@
<span>{label}</span>
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
<ItemIcon src={selected?.iconUrl} alt=""/>
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
</button>
{#if open}
<div class="builder-picker-panel">
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
<div>
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR SLOT</b><small>REMOVE CURRENT SELECTION</small></span><em>CLEAR</em></button>{/if}
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR</b><small>REMOVE SELECTION</small></span><em>CLEAR</em></button>{/if}
{#each results as item}
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
<ItemIcon src={item.iconUrl} alt=""/>
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
</button>
{:else}<p>NO MATCHING ITEMS</p>{/each}
{:else}<p>NO MATCHES</p>{/each}
</div>
</div>
{/if}
+24 -15
View File
@@ -24,6 +24,8 @@
} = $props();
let query = $state('');
let open = $state(false);
const selected = $derived(items.find((item) => item.id === value));
const visible = $derived(items.filter((item) =>
!query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
));
@@ -31,26 +33,33 @@
function choose(id: string) {
value = id;
onchange?.(id);
if (searchable) {
open = false;
query = '';
}
}
</script>
<fieldset class="builder-radio-grid" class:icon-only={iconOnly} style={`--builder-radio-columns:${Math.max(1, columns)}`}>
<legend>{label}</legend>
{#if searchable}
{#if searchable}<button type="button" class="builder-radio-toggle" aria-expanded={open} onclick={() => open = !open}><ItemIcon src={selected?.iconUrl} alt=""/><span><b>{selected?.name ?? 'SELECT OPTION'}</b><small>{selected?.category ?? 'NOT CONFIGURED'}</small></span><em>{open ? 'CLOSE' : 'CHANGE'}</em></button>{/if}
{#if searchable && open}
<input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/>
{/if}
<div>
{#each visible as item}
<label title={item.name}>
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
<span>
<ItemIcon src={item.iconUrl} alt=""/>
<b>{item.name}</b>
{#if item.category}<small>{item.category}</small>{/if}
</span>
</label>
{:else}
<p>NO MATCHING OPTIONS</p>
{/each}
</div>
{#if !searchable || open}
<div>
{#each visible as item}
<label title={item.name}>
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
<span>
<ItemIcon src={item.iconUrl} alt=""/>
<b>{item.name}</b>
{#if item.category}<small>{item.category}</small>{/if}
</span>
</label>
{:else}
<p>NO MATCHING OPTIONS</p>
{/each}
</div>
{/if}
</fieldset>
+1 -1
View File
@@ -14,4 +14,4 @@
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
</script>
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={className}/>
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={`item-icon ${className}`.trim()}/>
@@ -78,6 +78,7 @@
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
{ label: 'SHARED SKILL CHIP ABILITY', current: (currentSnapshot?.skillChipLinks as any[])?.[0]?.ability_id === specializationRecord?.id ? (currentSnapshot?.skillChipLinks as any[])?.[0]?.compatible_ability_id : (currentSnapshot?.skillChipLinks as any[])?.[0]?.ability_id, proposed: proposed.skillChipCompatibleAbilityId },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveCombatEffect'
+31 -31
View File
@@ -37,20 +37,20 @@
&& Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1]));
const issueLabel = (issue: string) => {
if (/not configured|required/i.test(issue)) return 'ACTION REQUIRED';
if (/incomplete/i.test(issue)) return 'DATA INCOMPLETE';
return 'CHECK CONFIGURATION';
if (/not configured|required|select|choose/i.test(issue)) return 'REQUIRED';
if (/incomplete/i.test(issue)) return 'INCOMPLETE';
return 'CHECK';
};
</script>
<section class="loadout-matrix" aria-label="Loadout matrix">
<header>
<span>LOADOUT MATRIX</span>
<b class:ready={configured}>{configured ? 'COMBAT READY' : 'CONFIGURATION INCOMPLETE'}</b>
<span>LOADOUT</span>
<b class:ready={configured}>{configured ? 'COMPLETE' : 'INCOMPLETE'}</b>
</header>
<div class="matrix-section doctrine-section">
<small class="section-label">DOCTRINE · ROOT CHOICE</small>
<small class="section-label">SPECIALIZATION FOCUS</small>
<button
type="button"
class="doctrine-cell"
@@ -60,27 +60,15 @@
onclick={() => onselect(doctrine.id)}
>
<ItemIcon src={doctrine.iconUrl} alt="" className="matrix-icon" />
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em class:visible={selected(doctrine)}>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
<div class="supply-line"><span>SUPPLIES 3 ABILITIES</span></div>
<div class="supply-line"><span>ABILITIES</span></div>
<div class="ability-cells">
{#each abilities as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
</button>
{/each}
</div>
</div>
<div class="matrix-section">
<small class="section-label">WEAPONS · PRIMARY + SECONDARY</small>
<div class="equipment-cells">
{#each weapons as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
{/each}
@@ -88,12 +76,25 @@
</div>
<div class="matrix-section">
<small class="section-label">GEAR · 6 POSITIONS</small>
<small class="section-label">WEAPONS</small>
<div class="equipment-cells">
{#each weapons as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
{/each}
</div>
</div>
<div class="matrix-section">
<small class="section-label">GEAR</small>
<div class="equipment-cells gear-cells">
{#each gear as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
{/each}
@@ -101,10 +102,10 @@
</div>
<div class="matrix-section protocol-section">
<small class="section-label">AGENT SYSTEM · ONE OS PROTOCOL</small>
<small class="section-label">OS PROTOCOL</small>
<button type="button" class:active={selected(protocol)} class:needs-attention={Boolean(protocol.issue)} aria-pressed={selected(protocol)} onclick={() => onselect(protocol.id)}>
<ItemIcon src={protocol.iconUrl} alt="" className="matrix-icon" />
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em class:visible={selected(protocol)}>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
</div>
@@ -112,21 +113,20 @@
<style>
.loadout-matrix{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}
.loadout-matrix>header{min-height:40px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;border-bottom:1px solid var(--shd-border);background:#0c0f10;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}
.loadout-matrix>header{height:auto;min-height:40px;position:static;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;border-bottom:1px solid var(--shd-border);background:#0c0f10;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}
.loadout-matrix>header b{color:var(--shd-warning);font-size:9px;text-align:right}.loadout-matrix>header b.ready{color:var(--shd-success)}
.matrix-section{padding:5px;border-bottom:1px solid var(--shd-border)}.matrix-section:last-child{border-bottom:0}
.section-label{display:block;padding:2px 3px 5px;color:var(--shd-text-dim);font-size:9px;font-weight:900;letter-spacing:.09em}
.doctrine-cell,.equipment-cells button,.ability-cells button,.protocol-section>button{min-width:0;display:grid;grid-template-columns:34px minmax(0,1fr) 3px;align-items:center;gap:7px;padding:7px;border:1px solid var(--shd-border-soft);background:var(--shd-builder-canvas);color:var(--shd-text);text-align:left}
.doctrine-cell{width:100%;min-height:76px;grid-template-columns:42px minmax(0,1fr) 3px}.doctrine-cell :global(.matrix-icon){width:40px;height:40px}
:global(.matrix-icon){width:30px;height:30px;object-fit:contain}
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button em{display:none}button em.visible{display:block}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
button:hover,button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);outline:none}button.active{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 2px 0 var(--shd-orange)}
button.needs-attention{border-bottom-color:color-mix(in srgb,var(--shd-danger) 68%,var(--shd-border-soft))}button.needs-attention>i{background:var(--shd-danger)}button mark{width:fit-content;max-width:100%;overflow:hidden;margin-top:2px;padding:2px 4px;border:1px solid color-mix(in srgb,var(--shd-danger) 58%,transparent);background:#1d1212;color:var(--shd-danger);font-size:8px;font-weight:900;letter-spacing:.07em;line-height:1.2;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}
.supply-line{height:22px;display:grid;place-items:center;background:linear-gradient(90deg,transparent 49.7%,var(--shd-orange) 49.7% 50.3%,transparent 50.3%)}.supply-line span{padding:0 6px;background:var(--shd-surface);color:var(--shd-orange);font-size:8px;font-weight:900;letter-spacing:.08em}
.ability-cells{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:3px}.ability-cells button{min-height:68px;grid-template-columns:25px minmax(0,1fr);padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
.ability-cells{display:grid;grid-template-columns:1fr;gap:3px}.ability-cells button{min-height:48px;grid-template-columns:25px minmax(0,1fr) 3px;padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
.equipment-cells{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px}.equipment-cells button{min-height:69px}.protocol-section>button{width:100%;min-height:68px}
button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
@media(max-width:1240px){.ability-cells{grid-template-columns:1fr}.ability-cells button{min-height:48px}}
@media(max-width:800px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}}
@media(max-width:800px) and (min-width:651px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}.ability-cells button{min-height:68px}}
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}.gear-cells{grid-template-columns:1fr}}
</style>
@@ -37,10 +37,9 @@
<section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}>
<div class="summary-head">
<div>
<p class="eyebrow"><span></span> KNOWN EQUIPMENT TOTALS</p>
<p class="eyebrow"><span></span> KNOWN STATS</p>
<h2 id={headingId}>Agent stats</h2>
</div>
<b>{summary.stats.length}</b>
</div>
<div class="summary-core">
{#each ['Firepower', 'Toughness', 'Engineering'] as name}
@@ -53,49 +52,57 @@
</div>
<div class="summary-scroll">
<div class="summary-section">
<header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>
<header><span>ATTRIBUTES</span><b>{summary.stats.length}</b></header>
{#each summary.stats as stat}
<div class="summary-stat">
<span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span>
<b>{unitValue(stat.value, stat.id)}</b>
</div>
{:else}
<p>Equip and configure items to calculate their known Attributes.</p>
<p>Equip items to see known Attributes.</p>
{/each}
</div>
<div class="summary-section">
<header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
<header><span>SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
{#each summary.activeBonuses as bonus}
<div class="summary-bonus">
<span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span>
<em>+{unitValue(bonus.value, bonus.statId)} {attribute(bonus.statId)?.name}</em>
</div>
{:else}
<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>
<p>No active bonuses.</p>
{/each}
</div>
<div class="summary-section">
<header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>
<header><span>TALENTS</span><b>{summary.equippedTalents.length}</b></header>
{#each summary.equippedTalents as equipped}
<div class="summary-talent">
<span>
<b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b>
<small>{equipped.source} · CONDITIONAL EFFECT NOT INCLUDED</small>
<em>{equipped.description}</em>
<details open={layout === 'page'}>
<summary><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source}{equipped.tierLabel ? ` · ${equipped.tierLabel}` : ''}</small></summary>
<p>{equipped.description}</p>
{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}
</span>
</details>
</div>
{:else}
<p>Selected equipment Talents appear here.</p>
<p>No Talents equipped.</p>
{/each}
</div>
</div>
<p class="known-stats-note">Totals reflect this saved loadout and the current canonical item definitions. They include fixed values, rolls, inherited profiles, enabled augments, set bonuses, Protocol effects, and static Specialization rank effects. Conditional Talent outcomes are reserved for the advanced combat model.</p>
<p class="known-stats-note">Known values only. Advanced effects are planned.</p>
</section>
<style>
.loadout-known-stats{background:var(--shd-surface)}
.summary-talent{grid-template-columns:1fr;cursor:default}
.summary-talent details,.summary-talent summary{min-width:0}
.summary-talent summary{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center;cursor:pointer;list-style:none}
.summary-talent summary::-webkit-details-marker{display:none}
.summary-talent summary:after{content:'+';grid-column:2;grid-row:1/3;color:var(--shd-orange);font-size:14px}
.summary-talent details[open] summary:after{content:'-'}
.summary-talent summary b,.summary-talent summary small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.summary-talent summary small{grid-column:1;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}
.summary-talent details>p{margin:8px 0 0;color:var(--shd-text-muted);font:var(--shd-type-telemetry)/1.45 var(--shd-font-editorial)}
.summary-talent summary:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:3px}
.known-stats-note{margin:0;padding:11px;border-top:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.45}
.page{border:1px solid var(--shd-border);border-top:2px solid var(--shd-success)}
.page .summary-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}
@@ -10,20 +10,20 @@
} = $props();
const capabilities = [
{ label: 'WEAPON OUTPUT', detail: 'Hit values, burst DPS, sustained DPS, and reload-aware firing cycles' },
{ label: 'SKILL SIMULATION', detail: 'Ability cadence, target counts, critical hits, and multi-shot behavior' },
{ label: 'ENCOUNTER MODEL', detail: 'Duration, accuracy, movement, target flow, rotations, and downtime' },
{ label: 'CONDITIONAL EFFECTS', detail: 'Talent triggers, stack buildup, cooldowns, uptime, and resource changes' }
{ label: 'WEAPON OUTPUT', detail: 'Hits and firing cycles' },
{ label: 'SKILL SIMULATION', detail: 'Timing and target caps' },
{ label: 'ENCOUNTER MODEL', detail: 'Duration and rotations' },
{ label: 'CONDITIONAL EFFECTS', detail: 'Triggers and uptime' }
];
</script>
<section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}>
<header>
<div><span>ADVANCED COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
<b>COMING SOON</b>
<div><span>COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
<b>PLANNED</b>
</header>
{#if variant === 'full'}
<p>Known equipment totals remain available now. Advanced damage projections will return after their calculation rules and scenario controls are ready for player use.</p>
<p>Known stats are live. Damage projections are planned.</p>
{/if}
<div class="planned-capabilities" aria-label="Planned combat-model capabilities">
{#each capabilities as capability}
@@ -0,0 +1,61 @@
<script lang="ts">
import ItemIcon from '$lib/components/ItemIcon.svelte';
import type { TalentTier } from '$lib/builder/loadout';
let {
label = 'TALENT TIER',
name,
talent,
value = $bindable(1),
onchange
}: {
label?: string;
name: string;
talent: { name: string; iconUrl?: string; tiers?: Array<{ tier: string }> };
value?: TalentTier;
onchange?: (tier: TalentTier) => void;
} = $props();
const available = (tier: TalentTier) => talent.tiers?.some((entry) => entry.tier === (tier === 2 ? 'II' : 'I')) ?? false;
const choose = (tier: TalentTier) => {
value = tier;
onchange?.(tier);
};
</script>
<fieldset class="talent-tier-choice">
<legend>{label}</legend>
<div>
{#each [1, 2] as tier}
<label class:tier-two={tier === 2}>
<input type="radio" {name} value={tier} checked={value === tier} disabled={!available(tier as TalentTier)} onchange={() => choose(tier as TalentTier)}/>
<span>
<ItemIcon src={talent.iconUrl} alt="" className="talent-tier-icon"/>
<small>TIER {tier === 2 ? 'II' : 'I'}</small>
<b>T{tier}</b>
<em>{available(tier as TalentTier) ? value === tier ? 'SELECTED' : 'SELECT' : 'UNAVAILABLE'}</em>
</span>
</label>
{/each}
</div>
</fieldset>
<style>
.talent-tier-choice{min-width:0;margin:8px 0 0;padding:0;border:0}
.talent-tier-choice legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.07em}
.talent-tier-choice>div{display:grid;grid-template-columns:1fr 1fr;gap:5px}
.talent-tier-choice label{min-width:0;cursor:pointer}
.talent-tier-choice input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}
.talent-tier-choice span{min-height:64px;display:grid;grid-template-columns:42px minmax(0,1fr) auto;grid-template-rows:auto auto;align-items:center;gap:1px 8px;padding:7px;border:1px solid var(--shd-border);background:var(--shd-builder-canvas);color:var(--shd-text);transition:var(--shd-transition)}
.talent-tier-choice :global(.talent-tier-icon){grid-row:1/3;width:40px;height:40px;object-fit:contain}
.talent-tier-choice .tier-two :global(.talent-tier-icon){filter:sepia(.3) saturate(1.45) hue-rotate(335deg) brightness(1.12)}
.talent-tier-choice small{align-self:end;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.06em}
.talent-tier-choice b{grid-column:3;grid-row:1/3;color:var(--shd-text-muted);font-size:18px}
.talent-tier-choice em{align-self:start;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900}
.talent-tier-choice label:hover span{border-color:var(--shd-border-strong);background:var(--shd-surface-active)}
.talent-tier-choice input:checked+span{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 0 -2px var(--shd-orange)}
.talent-tier-choice input:checked+span b,.talent-tier-choice input:checked+span em{color:var(--shd-orange)}
.talent-tier-choice input:focus-visible+span{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
.talent-tier-choice input:disabled+span{opacity:.38;cursor:not-allowed;filter:saturate(.35)}
@media(max-width:430px){.talent-tier-choice span{grid-template-columns:36px minmax(0,1fr);}.talent-tier-choice :global(.talent-tier-icon){width:34px;height:34px}.talent-tier-choice b{display:none}}
</style>
+2 -2
View File
@@ -77,7 +77,7 @@ export async function syncArticleTags(database: Database) {
`specialization-${item.id}`,
'specialization',
String(item.name),
`specialization-${slugify(String(item.name))}`,
`specialization-${slugify(String(item.name))}-${item.id}`,
'specialization',
item.id,
Number(item.active) ? 1 : 0,
@@ -93,7 +93,7 @@ export async function syncArticleTags(database: Database) {
`focus-${item.id}`,
'focus',
name,
`focus-${slugify(name)}`,
`focus-${slugify(name)}-${item.id}`,
'focus',
item.id,
Number(item.active) ? 1 : 0,
@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { connect, type Database } from '@tursodatabase/database';
const mocks = vi.hoisted(() => ({
audit: vi.fn(async () => undefined),
unlink: vi.fn(async () => undefined),
avatarDirectory: vi.fn(() => '/tmp/account-deletion-avatars'),
}));
vi.mock('$lib/server/audit', () => ({ recordIsacAudit: mocks.audit }));
vi.mock('node:fs/promises', () => ({ unlink: mocks.unlink }));
vi.mock('$lib/server/avatars', () => ({
avatarStorageDirectory: mocks.avatarDirectory,
}));
vi.mock('$lib/agents', () => ({
displayNameKey: (value: string) => value.toLowerCase(),
}));
import {
LastAdministratorDeletionError,
softDeleteAccount,
} from './account-deletion';
async function createDatabase(): Promise<Database> {
const database = await connect(':memory:');
await database.exec(`
CREATE TABLE users(
id TEXT PRIMARY KEY, username TEXT NOT NULL, login_username TEXT,
password_hash TEXT, discord_id TEXT, role TEXT NOT NULL
);
INSERT INTO users VALUES('user-1','agent','agent','hash','discord','user');
CREATE TABLE user_profiles(
user_id TEXT PRIMARY KEY, display_name TEXT, display_name_key TEXT,
bio_markdown TEXT NOT NULL DEFAULT '', is_public INTEGER NOT NULL DEFAULT 1,
bio_updated_at TEXT, updated_at TEXT
);
INSERT INTO user_profiles(user_id,display_name) VALUES('user-1','Agent');
CREATE TABLE user_avatars(user_id TEXT PRIMARY KEY, source TEXT, value TEXT);
CREATE TABLE avatar_reviews(
id TEXT PRIMARY KEY, user_id TEXT, status TEXT, reviewed_at TEXT
);
INSERT INTO user_avatars(user_id,source,value)
VALUES('user-1','upload','11111111-1111-4111-8111-111111111111.webp');
CREATE TABLE user_status(user_id TEXT PRIMARY KEY, blocked_at TEXT, banned_until TEXT);
CREATE TABLE deleted_accounts(
user_id TEXT PRIMARY KEY, original_username TEXT, deleted_at TEXT,
deleted_by TEXT, reason TEXT
);
CREATE TABLE sessions(id TEXT PRIMARY KEY, user_id TEXT);
CREATE TABLE magic_link_tokens(id TEXT PRIMARY KEY, user_id TEXT);
CREATE TABLE webauthn_challenges(id TEXT PRIMARY KEY, user_id TEXT);
CREATE TABLE passkeys(id TEXT PRIMARY KEY, user_id TEXT);
CREATE TABLE user_emails(id TEXT PRIMARY KEY, user_id TEXT);
CREATE TABLE oauth_states(id TEXT PRIMARY KEY, user_id TEXT);
CREATE TABLE discord_profiles(user_id TEXT PRIMARY KEY);
CREATE TABLE user_roles(user_id TEXT, role_id TEXT);
CREATE TABLE user_permissions(user_id TEXT, permission_id TEXT);
CREATE TABLE login_events(id TEXT PRIMARY KEY, user_id TEXT);
CREATE TABLE mfa_totp_factors(
id TEXT PRIMARY KEY, user_id TEXT, state TEXT, encrypted_secret TEXT,
encryption_key_id TEXT, revoked_at TEXT
);
CREATE TABLE mfa_recovery_codes(
id TEXT PRIMARY KEY, user_id TEXT, code_hash TEXT,
consumed_at TEXT, revoked_at TEXT
);
CREATE TABLE authentication_transactions(
id TEXT PRIMARY KEY, user_id TEXT, consumed_at TEXT
);
INSERT INTO mfa_totp_factors VALUES('factor-1','user-1','pending','mfa1.old','old-key',NULL);
INSERT INTO mfa_recovery_codes VALUES('recovery-1','user-1','hash',NULL,NULL);
INSERT INTO authentication_transactions VALUES('tx-1','user-1',NULL);
`);
return database;
}
describe('account deletion MFA erasure', () => {
let database: Database;
beforeEach(async () => {
database = await createDatabase();
mocks.audit.mockReset();
mocks.audit.mockResolvedValue(undefined);
mocks.unlink.mockReset();
mocks.unlink.mockResolvedValue(undefined);
});
afterEach(async () => database.close());
it('purges pending MFA transactions, recovery codes, and factor material', async () => {
expect(
await softDeleteAccount(database, 'user-1', 'admin-1', 'request', true),
).toBe(true);
expect(mocks.unlink).toHaveBeenCalledWith(
'/tmp/account-deletion-avatars/11111111-1111-4111-8111-111111111111.webp',
);
for (const table of [
'mfa_totp_factors',
'mfa_recovery_codes',
'authentication_transactions',
]) {
const row = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM ${table} WHERE user_id=?`,
)
).get('user-1');
expect(Number(row?.count)).toBe(0);
}
const user = await (
await database.prepare(
'SELECT username,password_hash,discord_id FROM users WHERE id=?',
)
).get('user-1');
expect(user).toMatchObject({
username: 'deleted-agent-user-1',
password_hash: null,
discord_id: null,
});
});
it('rolls back all deletion and MFA erasure when audit recording fails', async () => {
mocks.audit.mockRejectedValueOnce(new Error('audit unavailable'));
await expect(
softDeleteAccount(database, 'user-1', 'admin-1', 'request', true),
).rejects.toThrow('audit unavailable');
expect(mocks.unlink).not.toHaveBeenCalled();
const user = await (
await database.prepare(
'SELECT username,password_hash FROM users WHERE id=?',
)
).get('user-1');
expect(user).toMatchObject({ username: 'agent', password_hash: 'hash' });
const factor = await (
await database.prepare(
'SELECT encrypted_secret,state FROM mfa_totp_factors WHERE id=?',
)
).get('factor-1');
expect(factor).toMatchObject({
encrypted_secret: 'mfa1.old',
state: 'pending',
});
const deleted = await (
await database.prepare('SELECT 1 FROM deleted_accounts WHERE user_id=?')
).get('user-1');
expect(deleted).toBeUndefined();
});
it('does not clean the avatar when a transaction mutation fails', async () => {
await database.exec('DROP TABLE user_permissions');
await expect(
softDeleteAccount(database, 'user-1', 'admin-1', 'request', true),
).rejects.toThrow();
expect(mocks.unlink).not.toHaveBeenCalled();
const user = await (
await database.prepare('SELECT username FROM users WHERE id=?')
).get('user-1');
expect(user?.username).toBe('agent');
});
it('preserves committed deletion when post-commit avatar cleanup fails', async () => {
mocks.unlink.mockRejectedValueOnce(new Error('unlink failed'));
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
expect(
await softDeleteAccount(database, 'user-1', 'admin-1', 'request'),
).toBe(true);
expect(log).toHaveBeenCalledWith(
'[account-deletion] committed deletion; uploaded avatar cleanup failed.',
{ userId: 'user-1' },
);
const user = await (
await database.prepare('SELECT username FROM users WHERE id=?')
).get('user-1');
expect(user?.username).toBe('deleted-agent-user-1');
log.mockRestore();
});
it('rejects deletion of the sole active canonical administrator', async () => {
await database.exec(
"INSERT INTO user_roles(user_id,role_id) VALUES('user-1','role-admin')",
);
await expect(
softDeleteAccount(database, 'user-1', 'user-1', 'request'),
).rejects.toBeInstanceOf(LastAdministratorDeletionError);
const deleted = await (
await database.prepare('SELECT 1 FROM deleted_accounts WHERE user_id=?')
).get('user-1');
expect(deleted).toBeUndefined();
});
});
+200 -107
View File
@@ -1,7 +1,24 @@
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import { deleteUploadedAvatar } from '$lib/server/avatars';
import { randomBytes } from 'node:crypto';
import { unlink } from 'node:fs/promises';
import path from 'node:path';
import { avatarStorageDirectory } from '$lib/server/avatars';
import { displayNameKey } from '$lib/agents';
import { recordIsacAudit } from '$lib/server/audit';
import {
countActiveFullAdministrators,
isFullAdministrator,
} from './bootstrap';
const AVATAR_FILE_PATTERN = /^[0-9a-f-]{36}\.webp$/i;
type AvatarCleanup = { key: string } | { invalid: true } | null;
export class LastAdministratorDeletionError extends Error {
constructor() {
super('The final active full administrator cannot be deleted.');
this.name = 'LastAdministratorDeletionError';
}
}
export async function softDeleteAccount(
database: Database,
@@ -10,113 +27,189 @@ export async function softDeleteAccount(
reason: string | null,
purgeSecurityData = false,
): Promise<boolean> {
const user = await (
await database.prepare(`
SELECT users.username,users.login_username,user_profiles.display_name,deleted_accounts.deleted_at
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE users.id=?
`)
).get(userId);
if (!user || user.deleted_at) return false;
const now = new Date().toISOString();
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
const deletedDisplayName = `Deleted Agent ${userId.replaceAll('-', '').slice(0, 8)}`;
const deletion = database.transactionAsync(async (transaction) => {
const user = await (
await transaction.prepare(`
SELECT users.username,users.login_username,user_profiles.display_name,
user_status.blocked_at,user_status.banned_until,deleted_accounts.deleted_at
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE users.id=?
`)
).get(userId);
if (!user || user.deleted_at) return false;
const now = new Date().toISOString();
const targetIsActive =
!user.blocked_at &&
(!user.banned_until || String(user.banned_until) <= now);
if (
targetIsActive &&
(await isFullAdministrator(transaction as unknown as Database, userId)) &&
(await countActiveFullAdministrators(
transaction as unknown as Database,
new Date(now),
)) <= 1
)
throw new LastAdministratorDeletionError();
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
const deletedDisplayName = `Deleted Agent ${userId.replaceAll('-', '').slice(0, 8)}`;
await (
await database.prepare(
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)',
)
).run(
userId,
purgeSecurityData
? deletedUsername
: String(user.login_username ?? user.username),
now,
deletedBy,
reason,
);
await (
await database.prepare(
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`,
)
).run(deletedUsername, userId);
await (
await database.prepare(
`INSERT INTO user_profiles(user_id,display_name,display_name_key,bio_markdown,is_public,bio_updated_at,updated_at)
VALUES(?,?,?,?,0,?,?)
ON CONFLICT(user_id) DO UPDATE SET
display_name=excluded.display_name,
display_name_key=excluded.display_name_key,
bio_markdown='',
is_public=0,
bio_updated_at=excluded.bio_updated_at,
updated_at=excluded.updated_at`,
)
).run(
userId,
deletedDisplayName,
displayNameKey(deletedDisplayName),
'',
now,
now,
);
await (
await database.prepare('DELETE FROM sessions WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM webauthn_challenges WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM passkeys WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM user_emails WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM oauth_states WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
).run(userId);
await deleteUploadedAvatar(database, userId);
if (purgeSecurityData) {
await (
await database.prepare('DELETE FROM login_events WHERE user_id=?')
).run(userId);
}
await (
await database.prepare(
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
)
).run(
randomUUID(),
deletedBy,
'user_access',
userId,
'account.soft_deleted',
purgeSecurityData
? JSON.stringify({ purgedAtOwnerRequest: true })
: JSON.stringify({
username: user.username,
displayName: user.display_name,
}),
JSON.stringify({
username: deletedUsername,
displayName: 'Deleted Agent',
await transaction.prepare(
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)',
)
).run(
userId,
purgeSecurityData
? deletedUsername
: String(user.login_username ?? user.username),
now,
deletedBy,
reason,
securityDataPurged: purgeSecurityData,
}),
now,
);
);
await (
await transaction.prepare(
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`,
)
).run(deletedUsername, userId);
await (
await transaction.prepare(
`INSERT INTO user_profiles(user_id,display_name,display_name_key,bio_markdown,is_public,bio_updated_at,updated_at)
VALUES(?,?,?,?,0,?,?)
ON CONFLICT(user_id) DO UPDATE SET
display_name=excluded.display_name,
display_name_key=excluded.display_name_key,
bio_markdown='',
is_public=0,
bio_updated_at=excluded.bio_updated_at,
updated_at=excluded.updated_at`,
)
).run(
userId,
deletedDisplayName,
displayNameKey(deletedDisplayName),
'',
now,
now,
);
const avatar = await (
await transaction.prepare(
'SELECT source,value FROM user_avatars WHERE user_id=? LIMIT 1',
)
).get(userId);
const avatarCleanup: AvatarCleanup =
avatar?.source === 'upload'
? typeof avatar.value === 'string' &&
AVATAR_FILE_PATTERN.test(avatar.value)
? { key: avatar.value }
: { invalid: true }
: null;
await (
await transaction.prepare('DELETE FROM user_avatars WHERE user_id=?')
).run(userId);
await (
await transaction.prepare(
"UPDATE avatar_reviews SET status='removed',reviewed_at=COALESCE(reviewed_at,?) WHERE user_id=? AND status='pending'",
)
).run(now, userId);
await (
await transaction.prepare('DELETE FROM sessions WHERE user_id=?')
).run(userId);
await (
await transaction.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')
).run(userId);
await (
await transaction.prepare(
'DELETE FROM webauthn_challenges WHERE user_id=?',
)
).run(userId);
await (
await transaction.prepare('DELETE FROM passkeys WHERE user_id=?')
).run(userId);
await (
await transaction.prepare('DELETE FROM user_emails WHERE user_id=?')
).run(userId);
await (
await transaction.prepare('DELETE FROM oauth_states WHERE user_id=?')
).run(userId);
await (
await transaction.prepare('DELETE FROM discord_profiles WHERE user_id=?')
).run(userId);
await (
await transaction.prepare('DELETE FROM user_roles WHERE user_id=?')
).run(userId);
await (
await transaction.prepare('DELETE FROM user_permissions WHERE user_id=?')
).run(userId);
// Overwrite ciphertext and key metadata before deleting factor rows.
await (
await transaction.prepare(
"UPDATE mfa_totp_factors SET state='revoked',encrypted_secret=?,encryption_key_id='erased',revoked_at=? WHERE user_id=?",
)
).run(`erased:${randomBytes(32).toString('base64url')}`, now, userId);
await (
await transaction.prepare('DELETE FROM mfa_totp_factors WHERE user_id=?')
).run(userId);
await (
await transaction.prepare(
'DELETE FROM mfa_recovery_codes WHERE user_id=?',
)
).run(userId);
await (
await transaction.prepare(
'DELETE FROM authentication_transactions WHERE user_id=?',
)
).run(userId);
if (purgeSecurityData) {
await (
await transaction.prepare('DELETE FROM login_events WHERE user_id=?')
).run(userId);
}
await recordIsacAudit({
actorId: deletedBy,
category: 'user_access',
action: 'account.soft_deleted',
entityId: userId,
before: purgeSecurityData
? { purgedAtOwnerRequest: true }
: {
username: user.username,
displayName: user.display_name,
},
after: {
username: deletedUsername,
displayName: 'Deleted Agent',
reason,
securityDataPurged: purgeSecurityData,
mfaFactorsPurged: true,
recoveryCodesPurged: true,
authenticationTransactionsPurged: true,
},
database: transaction as unknown as Database,
createdAt: now,
});
return { deleted: true, avatarCleanup };
});
const committed = await deletion.immediate();
if (!committed) return false;
if (committed.avatarCleanup) {
try {
if ('invalid' in committed.avatarCleanup)
throw new Error('invalid avatar storage key');
await unlink(
path.join(avatarStorageDirectory(), committed.avatarCleanup.key),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error(
'[account-deletion] committed deletion; uploaded avatar cleanup failed.',
{ userId: userId.slice(0, 200) },
);
}
}
}
return true;
}
@@ -0,0 +1,338 @@
import { dev } from '$app/environment';
import { getDatabase } from '$lib/server/database';
import type { Database } from '@tursodatabase/database';
import type { Cookies } from '@sveltejs/kit';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
export const AUTHENTICATION_TRANSACTION_COOKIE = 'shd_auth_tx';
export const AUTHENTICATION_TRANSACTION_COOKIE_PATH = '/auth/two-factor';
export const AUTHENTICATION_TRANSACTION_SECONDS = 5 * 60;
export const AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT = 5;
export const AUTHENTICATION_TRANSACTION_PROVIDERS = [
'password',
'discord',
'magic_link',
'passkey',
] as const;
export type AuthenticationTransactionProvider =
(typeof AUTHENTICATION_TRANSACTION_PROVIDERS)[number];
export type AuthenticationTransactionKind = 'login' | 'step_up';
export type AuthenticationTransactionDatabase = Pick<
Database,
'prepare' | 'transactionAsync'
>;
export type AuthenticationTransactionTransaction = Pick<Database, 'prepare'>;
export type AuthenticationTransaction = {
id: string;
userId: string;
provider: AuthenticationTransactionProvider | null;
kind: AuthenticationTransactionKind;
returnPath: string;
requiredAal: 2;
attemptCount: number;
expiresAt: string;
};
type AuthenticationTransactionRow = {
id: string;
user_id: string;
provider: AuthenticationTransactionProvider | null;
kind: AuthenticationTransactionKind;
return_path: string;
required_aal: 2;
attempt_count: number;
expires_at: string;
consumed_at: string | null;
};
export class AuthenticationTransactionError extends Error {
constructor(message = 'Unable to create the authentication transaction.') {
super(message);
this.name = 'AuthenticationTransactionError';
}
}
function hashToken(token: string): string {
return createHash('sha256').update(token, 'utf8').digest('hex');
}
function databaseOrDefault(
database?: AuthenticationTransactionDatabase,
): Promise<AuthenticationTransactionDatabase> {
return database
? Promise.resolve(database)
: (getDatabase() as Promise<AuthenticationTransactionDatabase>);
}
function nowDate(value: Date | string | number | undefined): Date {
const date =
value instanceof Date ? new Date(value) : new Date(value ?? Date.now());
if (!Number.isFinite(date.getTime()))
throw new AuthenticationTransactionError();
return date;
}
/** Accept only a same-origin path, never an absolute or protocol-relative URL. */
export function validateAuthenticationReturnPath(value: string): string | null {
if (
typeof value !== 'string' ||
value.length < 1 ||
value.length > 2048 ||
!value.startsWith('/') ||
value.startsWith('//') ||
value.includes('\\') ||
value.includes('#') ||
/[\u0000-\u001f\u007f]/u.test(value)
)
return null;
try {
const decoded = decodeURIComponent(value);
if (
decoded.startsWith('//') ||
decoded.includes('\\') ||
/[\u0000-\u001f\u007f]/u.test(decoded)
)
return null;
const parsed = new URL(value, 'https://shd.invalid');
if (
parsed.origin !== 'https://shd.invalid' ||
!parsed.pathname.startsWith('/')
)
return null;
} catch {
return null;
}
return value;
}
function cookieOptions() {
return {
path: AUTHENTICATION_TRANSACTION_COOKIE_PATH,
httpOnly: true,
sameSite: 'lax' as const,
secure: !dev,
maxAge: AUTHENTICATION_TRANSACTION_SECONDS,
};
}
export function clearAuthenticationTransactionCookie(cookies: Cookies): void {
cookies.delete(AUTHENTICATION_TRANSACTION_COOKIE, {
path: AUTHENTICATION_TRANSACTION_COOKIE_PATH,
});
}
function fromRow(row: AuthenticationTransactionRow): AuthenticationTransaction {
return {
id: String(row.id),
userId: String(row.user_id),
provider: row.provider ?? null,
kind: row.kind,
returnPath: String(row.return_path),
requiredAal: 2,
attemptCount: Number(row.attempt_count),
expiresAt: String(row.expires_at),
};
}
export async function createAuthenticationTransaction(
cookies: Cookies,
databaseInput: AuthenticationTransactionDatabase | undefined,
options: {
userId: string;
kind: AuthenticationTransactionKind;
provider?: AuthenticationTransactionProvider | null;
returnPath: string;
now?: Date | string | number;
},
): Promise<AuthenticationTransaction> {
const returnPath = validateAuthenticationReturnPath(options.returnPath);
if (
!options.userId ||
!returnPath ||
(options.kind === 'login' && !options.provider)
)
throw new AuthenticationTransactionError();
const database = await databaseOrDefault(databaseInput);
const now = nowDate(options.now);
const createdAt = now.toISOString();
const expiresAt = new Date(
now.getTime() + AUTHENTICATION_TRANSACTION_SECONDS * 1000,
).toISOString();
const id = randomUUID();
const token = randomBytes(32).toString('base64url');
await (
await database.prepare(
`INSERT INTO authentication_transactions(
id,token_hash,user_id,provider,kind,return_path,required_aal,attempt_count,expires_at,created_at
) VALUES(?,?,?,?,?,?,?,?,?,?)`,
)
).run(
id,
hashToken(token),
options.userId,
options.provider ?? null,
options.kind,
returnPath,
2,
0,
expiresAt,
createdAt,
);
cookies.set(AUTHENTICATION_TRANSACTION_COOKIE, token, cookieOptions());
return {
id,
userId: options.userId,
provider: options.provider ?? null,
kind: options.kind,
returnPath,
requiredAal: 2,
attemptCount: 0,
expiresAt,
};
}
export const beginAuthenticationTransaction = createAuthenticationTransaction;
export async function getAuthenticationTransaction(
cookies: Cookies,
databaseInput?: AuthenticationTransactionDatabase,
nowValue?: Date | string | number,
): Promise<AuthenticationTransaction | null> {
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
if (!token) return null;
const now = nowDate(nowValue).toISOString();
const database = await databaseOrDefault(databaseInput);
const row = (await (
await database.prepare(
`SELECT id,user_id,provider,kind,return_path,required_aal,attempt_count,expires_at,consumed_at
FROM authentication_transactions
WHERE token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?`,
)
).get(hashToken(token), now, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT)) as
AuthenticationTransactionRow | undefined;
if (!row) {
clearAuthenticationTransactionCookie(cookies);
return null;
}
return fromRow(row);
}
/** Atomically account for one failed factor attempt. */
export async function recordAuthenticationAttempt(
cookies: Cookies,
databaseInput?: AuthenticationTransactionDatabase,
nowValue?: Date | string | number,
): Promise<{ accepted: boolean; attemptCount: number; locked: boolean }> {
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
if (!token) return { accepted: false, attemptCount: 0, locked: false };
const database = await databaseOrDefault(databaseInput);
const now = nowDate(nowValue).toISOString();
const attempt = database.transactionAsync(async (transaction) => {
const row = (await (
await transaction.prepare(
'SELECT id,attempt_count FROM authentication_transactions WHERE token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?',
)
).get(hashToken(token), now, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT)) as
{ id: string; attempt_count: number } | undefined;
if (!row) return null;
const updated = await (
await transaction.prepare(
'UPDATE authentication_transactions SET attempt_count=attempt_count+1 WHERE id=? AND consumed_at IS NULL AND attempt_count<?',
)
).run(row.id, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT);
return updated.changes === 1 ? Number(row.attempt_count) + 1 : null;
});
const attemptCount = await attempt.immediate();
if (attemptCount === null) {
clearAuthenticationTransactionCookie(cookies);
return { accepted: false, attemptCount: 0, locked: true };
}
return {
accepted: true,
attemptCount,
locked: false,
};
}
/**
* Load a transaction while the caller's immediate transaction holds the
* write claim. The conditional consume must happen before that transaction
* commits.
*/
export async function claimAuthenticationTransactionInTransaction(
transaction: AuthenticationTransactionTransaction,
cookies: Cookies,
nowValue?: Date | string | number,
): Promise<AuthenticationTransaction | null> {
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
if (!token) return null;
const now = nowDate(nowValue).toISOString();
const row = (await (
await transaction.prepare(
`SELECT id,user_id,provider,kind,return_path,required_aal,attempt_count,expires_at,consumed_at
FROM authentication_transactions
WHERE token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?`,
)
).get(hashToken(token), now, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT)) as
AuthenticationTransactionRow | undefined;
return row ? fromRow(row) : null;
}
/** Conditionally consume a transaction already loaded in this transaction. */
export async function consumeAuthenticationTransactionInTransaction(
transaction: AuthenticationTransactionTransaction,
cookies: Cookies,
transactionId: string,
nowValue?: Date | string | number,
): Promise<boolean> {
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
if (!token) return false;
const now = nowDate(nowValue).toISOString();
const consumed = await (
await transaction.prepare(
`UPDATE authentication_transactions SET consumed_at=?
WHERE id=? AND token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?`,
)
).run(
now,
transactionId,
hashToken(token),
now,
AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT,
);
return consumed.changes === 1;
}
/** Consume a transaction in one conditional write; replay cannot succeed. */
export async function consumeAuthenticationTransaction(
cookies: Cookies,
databaseInput?: AuthenticationTransactionDatabase,
nowValue?: Date | string | number,
): Promise<AuthenticationTransaction | null> {
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
if (!token) return null;
const database = await databaseOrDefault(databaseInput);
const now = nowDate(nowValue).toISOString();
const consume = database.transactionAsync(async (transaction) => {
const row = await claimAuthenticationTransactionInTransaction(
transaction,
cookies,
now,
);
if (!row) return null;
const consumed = await consumeAuthenticationTransactionInTransaction(
transaction,
cookies,
row.id,
now,
);
return consumed ? row : null;
});
const result = await consume.immediate();
clearAuthenticationTransactionCookie(cookies);
return result;
}
export const consumeAuthTransaction = consumeAuthenticationTransaction;
@@ -0,0 +1,182 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { connect, type Database } from '@tursodatabase/database';
const mocks = vi.hoisted(() => ({
audit: vi.fn(),
}));
vi.mock('$lib/server/audit', () => ({
recordIsacAudit: mocks.audit,
}));
import { applyAuthorityChange } from './authority-change';
import type { AuthUser } from './session';
const operator = {
id: 'operator-1',
aal: 2,
authLevel: 2,
aal2VerifiedAt: new Date().toISOString(),
} as AuthUser;
async function createDatabase(): Promise<Database> {
const database = await connect(':memory:');
await database.exec(`
CREATE TABLE users(id TEXT PRIMARY KEY, role TEXT NOT NULL);
CREATE TABLE roles(id TEXT PRIMARY KEY);
CREATE TABLE permissions(id TEXT PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE role_permissions(role_id TEXT, permission_id TEXT);
CREATE TABLE user_roles(user_id TEXT, role_id TEXT, PRIMARY KEY(user_id,role_id));
CREATE TABLE user_permissions(user_id TEXT, permission_id TEXT);
CREATE TABLE user_status(user_id TEXT PRIMARY KEY, blocked_at TEXT, banned_until TEXT);
CREATE TABLE deleted_accounts(user_id TEXT PRIMARY KEY);
CREATE TABLE sessions(id TEXT PRIMARY KEY, user_id TEXT);
CREATE TABLE mfa_totp_factors(id TEXT PRIMARY KEY, user_id TEXT, state TEXT);
CREATE TABLE mfa_recovery_codes(id TEXT PRIMARY KEY, user_id TEXT, consumed_at TEXT, revoked_at TEXT);
INSERT INTO users VALUES('operator-1','user'),('target-1','user');
INSERT INTO roles VALUES('role-admin'),('role-user');
INSERT INTO permissions VALUES('permission-system-admin','system.admin'),('permission-users-view','users.view');
INSERT INTO role_permissions VALUES('role-admin','permission-system-admin'),('role-user','permission-users-view');
INSERT INTO user_roles VALUES('operator-1','role-admin');
INSERT INTO mfa_totp_factors VALUES('operator-factor','operator-1','active');
INSERT INTO sessions VALUES('target-session','target-1');
`);
return database;
}
const access = (roleIds: string[], permissionIds: string[]) => ({
roleIds,
permissionIds,
});
describe('shared authority changes', () => {
let database: Database;
beforeEach(async () => {
database = await createDatabase();
mocks.audit.mockReset();
mocks.audit.mockResolvedValue(undefined);
});
afterEach(async () => database.close());
it('cannot revert an administrator grant by removing the final active authority', async () => {
await expect(
applyAuthorityChange({
database,
operator,
targetId: 'operator-1',
...access([], []),
expectedCurrent: access(['role-admin'], []),
}),
).rejects.toMatchObject({ status: 409 });
const role = await (
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
).get('operator-1');
expect(role.role_id).toBe('role-admin');
});
it('requires an MFA-ready target when reversion restores administrator authority', async () => {
await expect(
applyAuthorityChange({
database,
operator,
targetId: 'target-1',
...access(['role-admin'], []),
expectedCurrent: access([], []),
}),
).rejects.toMatchObject({ status: 400 });
const role = await (
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
).get('target-1');
expect(role).toBeUndefined();
});
it('requires recent AAL2 authentication for administrator reversion', async () => {
await database.exec(`
INSERT INTO mfa_totp_factors VALUES('target-factor','target-1','active');
INSERT INTO mfa_recovery_codes VALUES('target-code','target-1',NULL,NULL);
`);
await expect(
applyAuthorityChange({
database,
operator: {
...operator,
aal2VerifiedAt: new Date(Date.now() - 11 * 60_000).toISOString(),
} as never,
targetId: 'target-1',
...access(['role-admin'], []),
expectedCurrent: access([], []),
}),
).rejects.toMatchObject({ status: 403 });
});
it('revokes sessions and appends reversion audit evidence in the transaction', async () => {
await database.exec(`
INSERT INTO mfa_totp_factors VALUES('target-factor','target-1','active');
INSERT INTO mfa_recovery_codes VALUES('target-code','target-1',NULL,NULL);
`);
const result = await applyAuthorityChange({
database,
operator,
targetId: 'target-1',
...access(['role-admin'], []),
expectedCurrent: access([], []),
audit: {
actorId: 'operator-1',
category: 'audit_control',
action: 'audit.reverted',
entityId: 'event-1',
before: access([], []),
after: {
originalEventId: 'event-1',
restored: access(['role-admin'], []),
},
},
});
expect(result.after).toEqual(access(['role-admin'], []));
expect(
await (
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
).get(),
).toMatchObject({ count: 0 });
expect(mocks.audit).toHaveBeenCalledWith(
expect.objectContaining({
action: 'audit.reverted',
entityId: 'event-1',
database,
}),
);
});
it('rolls back authority and session changes when audit append fails', async () => {
await database.exec(`
INSERT INTO mfa_totp_factors VALUES('target-factor','target-1','active');
INSERT INTO mfa_recovery_codes VALUES('target-code','target-1',NULL,NULL);
`);
mocks.audit.mockRejectedValueOnce(new Error('audit failed'));
await expect(
applyAuthorityChange({
database,
operator,
targetId: 'target-1',
...access(['role-admin'], []),
expectedCurrent: access([], []),
}),
).rejects.toThrow('audit failed');
const role = await (
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
).get('target-1');
const sessions = await (
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
).get();
expect(role).toBeUndefined();
expect(Number(sessions.count)).toBe(1);
});
});
+272
View File
@@ -0,0 +1,272 @@
import type { Database } from '@tursodatabase/database';
import type { AuthUser } from './session';
import { hasRecentAal2, RECENT_AAL2_WINDOW_MS } from './authorization';
import { recordIsacAudit } from '$lib/server/audit';
import {
countActiveFullAdministrators,
hasActiveTotpFactor,
isFullAdministrator,
isMfaReady,
} from './bootstrap';
export type AuthoritySnapshot = {
roleIds: string[];
permissionIds: string[];
};
type AuditValue = Record<string, unknown> | unknown[] | null;
export type AuthorityAudit = {
actorId: string;
category: string;
action: string;
entityId: string;
before?: AuditValue;
after?: AuditValue;
};
export type AuthorityChangeInput = {
database: Database;
operator: AuthUser;
targetId: string;
roleIds: string[];
permissionIds: string[];
expectedCurrent?: AuthoritySnapshot;
audit?: AuthorityAudit;
};
export type AuthorityChangeResult = {
before: AuthoritySnapshot;
after: AuthoritySnapshot;
sessionsRevoked: number;
};
export class AuthorityChangeError extends Error {
readonly status: 400 | 403 | 404 | 409;
constructor(status: 400 | 403 | 404 | 409, message: string) {
super(message);
this.name = 'AuthorityChangeError';
this.status = status;
}
}
const sorted = (values: string[]) => [...values].sort();
function sameSnapshot(
left: AuthoritySnapshot,
right: AuthoritySnapshot,
): boolean {
return (
JSON.stringify({
roleIds: sorted(left.roleIds),
permissionIds: sorted(left.permissionIds),
}) ===
JSON.stringify({
roleIds: sorted(right.roleIds),
permissionIds: sorted(right.permissionIds),
})
);
}
function isRecentAal2(user: AuthUser): boolean {
return hasRecentAal2(user, Date.now());
}
export async function applyAuthorityChange(
input: AuthorityChangeInput,
): Promise<AuthorityChangeResult> {
const database = input.database;
await database.exec('BEGIN IMMEDIATE');
let transactionOpen = true;
const rollback = async () => {
if (!transactionOpen) return;
transactionOpen = false;
await database.exec('ROLLBACK');
};
try {
const operatorIsFullAdministrator =
(await isFullAdministrator(database, input.operator.id)) &&
input.operator.aal === 2 &&
(await hasActiveTotpFactor(database, input.operator.id));
const target = await (
await database.prepare('SELECT id FROM users WHERE id=?')
).get(input.targetId);
if (!target) throw new AuthorityChangeError(404, 'User not found.');
const roleIds = [...new Set(input.roleIds.map(String))];
const permissionIds = [...new Set(input.permissionIds.map(String))];
const validRoles = roleIds.length
? Number(
(
await (
await database.prepare(
`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`,
)
).get(...roleIds)
)?.count,
)
: 0;
const validPermissionRows = permissionIds.length
? await (
await database.prepare(
`SELECT id,name FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
)
).all(...permissionIds)
: [];
if (
validRoles !== roleIds.length ||
validPermissionRows.length !== permissionIds.length
)
throw new AuthorityChangeError(
400,
'One or more access selections are invalid.',
);
const before: AuthoritySnapshot = {
roleIds: (
await (
await database.prepare(
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
)
).all(input.targetId)
).map((row) => String(row.role_id)),
permissionIds: (
await (
await database.prepare(
'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id',
)
).all(input.targetId)
).map((row) => String(row.permission_id)),
};
if (input.expectedCurrent && !sameSnapshot(before, input.expectedCurrent))
throw new AuthorityChangeError(
409,
'The user’s access changed after this event. Reversion was blocked.',
);
const currentRoleSystemAdmin = Boolean(
await (
await database.prepare(`
SELECT 1
FROM user_roles
JOIN role_permissions ON role_permissions.role_id=user_roles.role_id
JOIN permissions ON permissions.id=role_permissions.permission_id
WHERE user_roles.user_id=? AND permissions.name='system.admin'
LIMIT 1
`)
).get(input.targetId),
);
const currentDirectSystemAdmin = before.permissionIds.includes(
'permission-system-admin',
);
const currentSystemAdmin =
currentRoleSystemAdmin || currentDirectSystemAdmin;
const requestedDirectSystemAdmin = validPermissionRows.some(
(permission) => String(permission.name) === 'system.admin',
);
const requestedRoleSystemAdmin = Boolean(
roleIds.length &&
(await (
await database.prepare(`
SELECT 1
FROM role_permissions
JOIN permissions ON permissions.id=role_permissions.permission_id
WHERE role_permissions.role_id IN (${roleIds.map(() => '?').join(',')})
AND permissions.name='system.admin'
LIMIT 1
`)
).get(...roleIds)),
);
const requestedSystemAdmin =
requestedDirectSystemAdmin || requestedRoleSystemAdmin;
const requestedCanonicalAdministrator = roleIds.includes('role-admin');
const targetIsFullAdministrator = await isFullAdministrator(
database,
input.targetId,
);
const targetHadAdministratorAuthority =
targetIsFullAdministrator || before.roleIds.includes('role-admin');
const changesAdministratorAccess =
targetIsFullAdministrator ||
currentSystemAdmin !== requestedSystemAdmin ||
currentDirectSystemAdmin !== requestedDirectSystemAdmin ||
requestedCanonicalAdministrator !== before.roleIds.includes('role-admin');
if (changesAdministratorAccess && !operatorIsFullAdministrator)
throw new AuthorityChangeError(
403,
'Only a full administrator can grant, remove, or modify administrator access.',
);
if (changesAdministratorAccess && !isRecentAal2(input.operator))
throw new AuthorityChangeError(
403,
`Recent AAL2 authentication is required for administrator changes within ${RECENT_AAL2_WINDOW_MS / 60_000} minutes.`,
);
if (
(requestedSystemAdmin || requestedCanonicalAdministrator) &&
(!targetHadAdministratorAuthority ||
(requestedDirectSystemAdmin && !currentDirectSystemAdmin)) &&
!(await isMfaReady(database, input.targetId))
)
throw new AuthorityChangeError(
400,
'The target must have an active TOTP factor and an unused recovery code before receiving administrator authority.',
);
await (
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
).run(input.targetId);
await (
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
).run(input.targetId);
for (const roleId of roleIds)
await (
await database.prepare(
'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)',
)
).run(input.targetId, roleId);
for (const permissionId of permissionIds)
await (
await database.prepare(
'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)',
)
).run(input.targetId, permissionId);
let sessionsRevoked = 0;
if (changesAdministratorAccess) {
const revoked = await (
await database.prepare('DELETE FROM sessions WHERE user_id=?')
).run(input.targetId);
sessionsRevoked = Number(revoked.changes ?? 0);
}
if ((await countActiveFullAdministrators(database)) < 1)
throw new AuthorityChangeError(
409,
'The final active full administrator cannot lose administrator access.',
);
const after: AuthoritySnapshot = { roleIds, permissionIds };
const audit = input.audit ?? {
actorId: input.operator.id,
category: 'user_access',
action: 'access.updated',
entityId: input.targetId,
before,
after,
};
await recordIsacAudit({
...audit,
before: audit.before === undefined ? before : audit.before,
after: audit.after === undefined ? after : audit.after,
database,
});
await database.exec('COMMIT');
transactionOpen = false;
return { before, after, sessionsRevoked };
} catch (error) {
await rollback();
throw error;
}
}
+78
View File
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
database: { prepare: vi.fn() },
isFullAdministrator: vi.fn(),
hasActiveTotpFactor: vi.fn(),
isMfaReady: vi.fn(),
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
vi.mock('./bootstrap', () => ({
isFullAdministrator: mocks.isFullAdministrator,
hasActiveTotpFactor: mocks.hasActiveTotpFactor,
isMfaReady: mocks.isMfaReady,
}));
import {
getPermissions,
hasRecentAal2,
requireRecentAal2,
} from './authorization';
const user = (aal: 1 | 2, aal2VerifiedAt: string | null = null) =>
({
id: 'user-1',
aal,
authLevel: aal,
aal2VerifiedAt,
}) as never;
describe('privileged authorization assurance', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.isFullAdministrator.mockResolvedValue(false);
mocks.hasActiveTotpFactor.mockResolvedValue(false);
mocks.database.prepare.mockResolvedValue({
all: vi.fn(async () => [
{ name: 'system.admin' },
{ name: 'users.view' },
]),
});
});
it('does not turn an AAL1 or unenrolled administrator into wildcard access', async () => {
mocks.isFullAdministrator.mockResolvedValue(true);
const permissions = await getPermissions(user(1));
expect(permissions).toContain('users.view');
expect(permissions).toContain('isac.access');
expect(permissions).not.toContain('*');
expect(permissions).not.toContain('system.admin');
});
it('requires both canonical administration, active TOTP, and AAL2 for wildcard access', async () => {
mocks.isFullAdministrator.mockResolvedValue(true);
mocks.hasActiveTotpFactor.mockResolvedValue(true);
const permissions = await getPermissions(user(2));
expect(permissions).toEqual(['*']);
});
it('does not treat a direct system.admin grant as unrestricted access', async () => {
const permissions = await getPermissions(user(2));
expect(permissions).toContain('users.view');
expect(permissions).not.toContain('*');
expect(permissions).not.toContain('system.admin');
});
it('recognizes only AAL2 evidence from the last ten minutes', () => {
const now = Date.parse('2026-08-07T12:00:00.000Z');
expect(hasRecentAal2(user(1, '2026-08-07T11:59:00.000Z'), now)).toBe(false);
expect(hasRecentAal2(user(2, '2026-08-07T11:59:00.000Z'), now)).toBe(true);
expect(hasRecentAal2(user(2, '2026-08-07T11:49:59.999Z'), now)).toBe(false);
expect(() =>
requireRecentAal2(user(1, '2026-08-07T11:59:00.000Z'), now),
).toThrow();
});
});
+73 -5
View File
@@ -1,6 +1,13 @@
import { getDatabase } from '$lib/server/database';
import type { AuthUser } from './session';
import { error } from '@sveltejs/kit';
import {
hasActiveTotpFactor,
isFullAdministrator,
isMfaReady,
} from './bootstrap';
export const RECENT_AAL2_WINDOW_MS = 10 * 60 * 1000;
const REGISTERED_AGENT_PERMISSIONS = [
'isac.access',
@@ -10,8 +17,14 @@ const REGISTERED_AGENT_PERMISSIONS = [
export async function getPermissions(user: AuthUser | null): Promise<string[]> {
if (!user) return [];
if (user.role === 'admin') return ['*'];
const database = await getDatabase();
const canonicalAdministrator = await isFullAdministrator(database, user.id);
if (
canonicalAdministrator &&
user.aal === 2 &&
(await hasActiveTotpFactor(database, user.id))
)
return ['*'];
const rows = await (
await database.prepare(`
SELECT permissions.name FROM permissions
@@ -27,10 +40,12 @@ export async function getPermissions(user: AuthUser | null): Promise<string[]> {
const permissions = [
...new Set([
...REGISTERED_AGENT_PERMISSIONS,
...rows.map((row) => String(row.name)),
...rows
.map((row) => String(row.name))
.filter((permission) => permission !== 'system.admin'),
]),
];
return permissions.includes('system.admin') ? ['*'] : permissions;
return permissions;
}
export async function hasPermission(
@@ -67,7 +82,60 @@ export async function requireDataView(
return user;
}
export function requireAdmin(user: AuthUser | null): asserts user is AuthUser {
export async function requireAdmin(user: AuthUser | null): Promise<AuthUser> {
if (!user) error(401, 'Sign in required');
if (user.role !== 'admin') error(403, 'Administrator permission required');
const database = await getDatabase();
if (!(await isFullAdministrator(database, user.id)))
error(403, 'Administrator permission required');
if (!(await hasActiveTotpFactor(database, user.id)))
error(403, 'Administrator MFA enrollment required');
requireAal2(user);
return user;
}
export function hasAal2(user: AuthUser | null): boolean {
return user?.aal === 2;
}
export function hasRecentAal2(
user: AuthUser | null,
now = Date.now(),
): boolean {
if (!hasAal2(user) || !user?.aal2VerifiedAt) return false;
const verifiedAt = Date.parse(user.aal2VerifiedAt);
return (
Number.isFinite(verifiedAt) &&
verifiedAt <= now &&
now - verifiedAt <= RECENT_AAL2_WINDOW_MS
);
}
export function requireAal2(user: AuthUser | null): AuthUser {
if (!user) error(401, 'Sign in required');
if (!hasAal2(user)) error(403, 'AAL2 authentication required');
return user;
}
export function requireRecentAal2(
user: AuthUser | null,
now = Date.now(),
): AuthUser {
if (!user) error(401, 'Sign in required');
if (!hasRecentAal2(user, now))
error(403, 'Recent AAL2 authentication required');
return user;
}
export async function isAdministratorMfaReady(
user: AuthUser | null,
): Promise<boolean> {
if (!user) return false;
return isMfaReady(await getDatabase(), user.id);
}
export async function targetCanReceiveAdministratorAuthority(
database: Parameters<typeof isMfaReady>[0],
userId: string,
): Promise<boolean> {
return isMfaReady(database, userId);
}
+84 -13
View File
@@ -1,20 +1,91 @@
import type { Database } from '@tursodatabase/database';
/**
* Atomically promotes the authenticated user when no administrator exists.
* The conditional UPDATE ensures concurrent first sign-ins cannot both win.
* Returns whether the account has the canonical unrestricted administrator
* relationship. The role column is a legacy projection and is not authority.
* Direct system.admin grants are intentionally not treated as full
* administration; they cannot bootstrap or escalate an account.
*/
export async function ensureBootstrapAdmin(
export async function isFullAdministrator(
database: Database,
userId: string,
): Promise<void> {
const promote = await database.prepare(`
UPDATE users
SET role = 'admin'
WHERE id = ?
AND NOT EXISTS (
SELECT 1 FROM users WHERE role = 'admin'
)
`);
await promote.run(userId);
): Promise<boolean> {
const row = await (
await database.prepare(`
SELECT 1
FROM users
WHERE users.id=?
AND EXISTS (
SELECT 1
FROM user_roles
WHERE user_roles.user_id=users.id
AND user_roles.role_id='role-admin'
)
LIMIT 1
`)
).get(userId);
return Boolean(row);
}
/** Count active accounts holding the canonical unrestricted relationship. */
export async function countActiveFullAdministrators(
database: Database,
now = new Date(),
): Promise<number> {
const row = await (
await database.prepare(`
SELECT COUNT(DISTINCT users.id) AS count
FROM users
LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE deleted_accounts.user_id IS NULL
AND user_status.blocked_at IS NULL
AND (
user_status.banned_until IS NULL
OR user_status.banned_until<=?
)
AND EXISTS (
SELECT 1 FROM user_roles
WHERE user_roles.user_id=users.id AND user_roles.role_id='role-admin'
)
`)
).get(now.toISOString());
const count = Number(row?.count);
if (!Number.isSafeInteger(count) || count < 0)
throw new Error('Unable to determine active administrator count.');
return count;
}
export async function hasActiveTotpFactor(
database: Database,
userId: string,
): Promise<boolean> {
const row = await (
await database.prepare(
"SELECT 1 FROM mfa_totp_factors WHERE user_id=? AND state='active' LIMIT 1",
)
).get(userId);
return Boolean(row);
}
export async function hasUnusedRecoveryCode(
database: Database,
userId: string,
): Promise<boolean> {
const row = await (
await database.prepare(
'SELECT 1 FROM mfa_recovery_codes WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL LIMIT 1',
)
).get(userId);
return Boolean(row);
}
export async function isMfaReady(
database: Database,
userId: string,
): Promise<boolean> {
return (
(await hasActiveTotpFactor(database, userId)) &&
(await hasUnusedRecoveryCode(database, userId))
);
}
+227
View File
@@ -0,0 +1,227 @@
import { env } from '$env/dynamic/private';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
export const MFA_TOTP_PARAMETERS = {
algorithm: 'SHA1',
digits: 6,
periodSeconds: 30,
} as const;
const ENVELOPE_VERSION = 'mfa1';
const KEY_BYTES = 32;
const IV_BYTES = 12;
const TAG_BYTES = 16;
export type MfaEncryptionKey = {
id: string;
key: Buffer;
};
export class MfaCryptoError extends Error {
constructor() {
super('Unable to protect the MFA secret.');
this.name = 'MfaCryptoError';
}
}
export type DecryptedTotpSecret = {
secret: Buffer;
keyId: string;
needsRewrap: boolean;
};
type TotpAadContext = {
factorId: string;
userId: string;
};
function isCanonicalBase64Url(value: string): boolean {
if (!/^[A-Za-z0-9_-]+$/.test(value)) return false;
try {
const decoded = Buffer.from(value, 'base64url');
return (
decoded.length === KEY_BYTES && decoded.toString('base64url') === value
);
} catch {
return false;
}
}
/** Parse the app-only keyring. No fallback or generated key is permitted. */
export function parseMfaTotpEncryptionKeys(raw: string): MfaEncryptionKey[] {
const entries = raw.split(',').map((entry) => entry.trim());
if (
entries.length < 1 ||
entries.length > 3 ||
entries.some((entry) => !entry)
)
throw new MfaCryptoError();
const ids = new Set<string>();
const encodedKeys = new Set<string>();
const keys: MfaEncryptionKey[] = [];
for (const entry of entries) {
const separator = entry.indexOf(':');
const id = separator > 0 ? entry.slice(0, separator) : '';
const encoded = separator > 0 ? entry.slice(separator + 1) : '';
if (
!/^[A-Za-z0-9_-]{1,64}$/.test(id) ||
encoded.length !== 43 ||
!isCanonicalBase64Url(encoded) ||
ids.has(id) ||
encodedKeys.has(encoded)
)
throw new MfaCryptoError();
ids.add(id);
encodedKeys.add(encoded);
keys.push({ id, key: Buffer.from(encoded, 'base64url') });
}
return keys;
}
export function getMfaTotpEncryptionKeys(): MfaEncryptionKey[] {
const raw = env.MFA_TOTP_ENCRYPTION_KEYS?.trim();
if (!raw) throw new MfaCryptoError();
return parseMfaTotpEncryptionKeys(raw);
}
function aadFor({ factorId, userId }: TotpAadContext): Buffer {
if (!factorId || !userId) throw new MfaCryptoError();
return Buffer.from(
JSON.stringify({
version: ENVELOPE_VERSION,
factorId,
userId,
algorithm: MFA_TOTP_PARAMETERS.algorithm,
digits: MFA_TOTP_PARAMETERS.digits,
periodSeconds: MFA_TOTP_PARAMETERS.periodSeconds,
}),
'utf8',
);
}
function activeKey(keys: MfaEncryptionKey[]): MfaEncryptionKey {
const key = keys[0];
if (!key || key.key.length !== KEY_BYTES) throw new MfaCryptoError();
return key;
}
function envelopePart(value: Buffer): string {
return value.toString('base64url');
}
function decodeEnvelope(envelope: string): {
keyId: string;
iv: Buffer;
ciphertext: Buffer;
tag: Buffer;
} {
const parts = envelope.split('.');
if (parts.length !== 5 || parts[0] !== ENVELOPE_VERSION)
throw new MfaCryptoError();
const [, keyId, encodedIv, encodedCiphertext, encodedTag] = parts;
if (
!/^[A-Za-z0-9_-]{1,64}$/.test(keyId) ||
!encodedIv ||
!encodedCiphertext ||
!encodedTag
)
throw new MfaCryptoError();
try {
const iv = Buffer.from(encodedIv, 'base64url');
const ciphertext = Buffer.from(encodedCiphertext, 'base64url');
const tag = Buffer.from(encodedTag, 'base64url');
if (
iv.length !== IV_BYTES ||
ciphertext.length !== 20 ||
tag.length !== TAG_BYTES ||
iv.toString('base64url') !== encodedIv ||
ciphertext.toString('base64url') !== encodedCiphertext ||
tag.toString('base64url') !== encodedTag
)
throw new MfaCryptoError();
return { keyId, iv, ciphertext, tag };
} catch {
throw new MfaCryptoError();
}
}
function keyById(keys: MfaEncryptionKey[], id: string): MfaEncryptionKey {
const key = keys.find((candidate) => candidate.id === id);
if (!key || key.key.length !== KEY_BYTES) throw new MfaCryptoError();
return key;
}
export function encryptTotpSecret(
context: TotpAadContext & { secret: Uint8Array },
keys: MfaEncryptionKey[] = getMfaTotpEncryptionKeys(),
): string {
try {
if (context.secret.length !== 20) throw new MfaCryptoError();
const key = activeKey(keys);
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv('aes-256-gcm', key.key, iv);
cipher.setAAD(aadFor(context));
const ciphertext = Buffer.concat([
cipher.update(context.secret),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return [
ENVELOPE_VERSION,
key.id,
envelopePart(iv),
envelopePart(ciphertext),
envelopePart(tag),
].join('.');
} catch (error) {
if (error instanceof MfaCryptoError) throw error;
throw new MfaCryptoError();
}
}
export function decryptTotpSecret(
context: TotpAadContext & { envelope: string },
keys: MfaEncryptionKey[] = getMfaTotpEncryptionKeys(),
): DecryptedTotpSecret {
try {
const envelope = decodeEnvelope(context.envelope);
const key = keyById(keys, envelope.keyId);
const decipher = createDecipheriv('aes-256-gcm', key.key, envelope.iv);
decipher.setAAD(aadFor(context));
decipher.setAuthTag(envelope.tag);
const secret = Buffer.concat([
decipher.update(envelope.ciphertext),
decipher.final(),
]);
if (secret.length !== 20) throw new MfaCryptoError();
return {
secret,
keyId: key.id,
needsRewrap: key.id !== activeKey(keys).id,
};
} catch (error) {
if (error instanceof MfaCryptoError) throw error;
throw new MfaCryptoError();
}
}
export function rewrapTotpSecret(
context: TotpAadContext & { envelope: string },
keys: MfaEncryptionKey[] = getMfaTotpEncryptionKeys(),
): { envelope: string; previousKeyId: string; activeKeyId: string } {
const decrypted = decryptTotpSecret(context, keys);
const active = activeKey(keys);
return {
envelope: encryptTotpSecret(
{
factorId: context.factorId,
userId: context.userId,
secret: decrypted.secret,
},
keys,
),
previousKeyId: decrypted.keyId,
activeKeyId: active.id,
};
}
+397
View File
@@ -0,0 +1,397 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { connect, type Database } from '@tursodatabase/database';
import { Secret } from 'otpauth';
const { keyA, keyB } = vi.hoisted(() => ({
keyA: Buffer.alloc(32, 0x11).toString('base64url'),
keyB: Buffer.alloc(32, 0x22).toString('base64url'),
}));
vi.mock('$env/dynamic/private', () => ({
env: {
MFA_TOTP_ENCRYPTION_KEYS: `active:${keyA},retained:${keyB}`,
},
}));
import {
decryptTotpSecret,
encryptTotpSecret,
parseMfaTotpEncryptionKeys,
rewrapTotpSecret,
MfaCryptoError,
} from './mfa-crypto';
import {
beginTotpEnrollment,
generateTotpCode,
verifyTotpFactor,
} from './mfa-totp';
import {
consumeRecoveryCode,
formatRecoveryCode,
issueRecoveryCodes,
normalizeRecoveryCode,
} from './mfa-recovery';
import {
consumeAuthenticationTransaction,
createAuthenticationTransaction,
getAuthenticationTransaction,
recordAuthenticationAttempt,
validateAuthenticationReturnPath,
} from './authentication-transactions';
const now = new Date('2026-08-07T12:00:00.000Z');
function cookieJar() {
const values = new Map<string, string>();
return {
values,
get: (name: string) => values.get(name),
set: vi.fn((name: string, value: string) => values.set(name, value)),
delete: vi.fn((name: string) => values.delete(name)),
};
}
async function mfaDatabase(userId = 'user-1'): Promise<Database> {
const database = await connect(':memory:');
await database.exec(`
CREATE TABLE users (id TEXT PRIMARY KEY);
INSERT INTO users(id) VALUES ('${userId}');
CREATE TABLE mfa_totp_factors (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, state TEXT NOT NULL,
encrypted_secret TEXT NOT NULL, encryption_key_id TEXT NOT NULL,
issuer TEXT NOT NULL, account_label TEXT NOT NULL, algorithm TEXT NOT NULL,
digits INTEGER NOT NULL, period_seconds INTEGER NOT NULL,
last_counter INTEGER, failed_attempts INTEGER NOT NULL DEFAULT 0,
locked_until TEXT, expires_at TEXT, created_at TEXT NOT NULL,
activated_at TEXT, revoked_at TEXT
);
CREATE TABLE mfa_recovery_codes (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, code_hash TEXT NOT NULL,
created_at TEXT NOT NULL, consumed_at TEXT, revoked_at TEXT
);
`);
return database;
}
async function transactionDatabase(): Promise<Database> {
const database = await connect(':memory:');
await database.exec(`
CREATE TABLE authentication_transactions (
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL,
provider TEXT, kind TEXT NOT NULL, return_path TEXT NOT NULL, required_aal INTEGER NOT NULL,
attempt_count INTEGER NOT NULL, expires_at TEXT NOT NULL, consumed_at TEXT,
created_at TEXT NOT NULL
);
`);
return database;
}
afterEach(() => vi.restoreAllMocks());
describe('MFA encryption foundation', () => {
it('rejects malformed keyrings and reports rotation without exposing keys', () => {
expect(() => parseMfaTotpEncryptionKeys('')).toThrow(MfaCryptoError);
expect(() => parseMfaTotpEncryptionKeys(`one:${keyA},two:${keyA}`)).toThrow(
MfaCryptoError,
);
expect(() =>
parseMfaTotpEncryptionKeys(`one:${keyA}=,two:${keyB}`),
).toThrow(MfaCryptoError);
const secret = Buffer.alloc(20, 0x33);
const oldKeys = parseMfaTotpEncryptionKeys(`old:${keyB},active:${keyA}`);
const envelope = encryptTotpSecret(
{ factorId: 'factor-1', userId: 'user-1', secret },
oldKeys,
);
const decrypted = decryptTotpSecret(
{ factorId: 'factor-1', userId: 'user-1', envelope },
parseMfaTotpEncryptionKeys(`active:${keyA},old:${keyB}`),
);
expect(decrypted.secret).toEqual(secret);
expect(decrypted.needsRewrap).toBe(true);
const rewrapped = rewrapTotpSecret(
{ factorId: 'factor-1', userId: 'user-1', envelope },
parseMfaTotpEncryptionKeys(`active:${keyA},old:${keyB}`),
);
expect(rewrapped.previousKeyId).toBe('old');
expect(rewrapped.activeKeyId).toBe('active');
expect(() =>
decryptTotpSecret(
{ factorId: 'factor-2', userId: 'user-1', envelope },
oldKeys,
),
).toThrow(MfaCryptoError);
expect(() =>
decryptTotpSecret(
{ factorId: 'factor-1', userId: 'user-1', envelope: `${envelope}x` },
oldKeys,
),
).toThrow(MfaCryptoError);
});
});
describe('TOTP factors', () => {
let database: Database;
beforeEach(async () => {
database = await mfaDatabase();
});
afterEach(async () => database.close());
it('uses the one-period window and atomically rejects counter replay', async () => {
const enrollment = await beginTotpEnrollment(database, {
userId: 'user-1',
now,
});
const secret = Secret.fromBase32(enrollment.secretBase32).bytes;
const nextPeriodCode = generateTotpCode(secret, now.getTime() + 30_000);
const verified = await verifyTotpFactor(database, {
userId: 'user-1',
factorId: enrollment.factorId,
token: nextPeriodCode,
now,
});
expect(verified.status).toBe('verified');
const replay = await verifyTotpFactor(database, {
userId: 'user-1',
factorId: enrollment.factorId,
token: nextPeriodCode,
now,
});
expect(replay.status).toBe('replayed');
});
it('locks after five failures and expires pending enrollment', async () => {
const enrollment = await beginTotpEnrollment(database, {
userId: 'user-1',
now,
});
for (let attempt = 0; attempt < 5; attempt += 1) {
await verifyTotpFactor(database, {
userId: 'user-1',
factorId: enrollment.factorId,
token: '000000',
now,
});
}
const locked = await verifyTotpFactor(database, {
userId: 'user-1',
factorId: enrollment.factorId,
token: '000000',
now,
});
expect(locked.status).toBe('locked');
const expired = await verifyTotpFactor(database, {
userId: 'user-1',
factorId: enrollment.factorId,
token: '000000',
now: new Date(now.getTime() + 11 * 60_000),
});
expect(expired.status).toBe('expired');
});
it('rewraps a verified factor under the active encryption key atomically', async () => {
const secret = Buffer.alloc(20, 0x44);
const factorId = 'factor-old-key';
const oldEnvelope = encryptTotpSecret(
{ factorId, userId: 'user-1', secret },
parseMfaTotpEncryptionKeys(`retained:${keyB},active:${keyA}`),
);
await (
await database.prepare(
`INSERT INTO mfa_totp_factors(
id,user_id,state,encrypted_secret,encryption_key_id,issuer,account_label,
algorithm,digits,period_seconds,failed_attempts,created_at,activated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
)
).run(
factorId,
'user-1',
'active',
oldEnvelope,
'retained',
'SHD.build',
'user-1',
'SHA1',
6,
30,
0,
now.toISOString(),
now.toISOString(),
);
const result = await verifyTotpFactor(database, {
userId: 'user-1',
factorId,
token: generateTotpCode(secret, now.getTime()),
now,
});
expect(result).toMatchObject({
status: 'verified',
needsRewrap: true,
keyId: 'retained',
});
const row = await (
await database.prepare(
'SELECT encrypted_secret,encryption_key_id FROM mfa_totp_factors WHERE id=?',
)
).get(factorId);
expect(row.encryption_key_id).toBe('active');
expect(row.encrypted_secret).not.toBe(oldEnvelope);
expect(
decryptTotpSecret(
{
factorId,
userId: 'user-1',
envelope: String(row.encrypted_secret),
},
parseMfaTotpEncryptionKeys(`active:${keyA},retained:${keyB}`),
).secret,
).toEqual(secret);
});
});
describe('recovery codes', () => {
let database: Database;
beforeEach(async () => {
database = await mfaDatabase();
});
afterEach(async () => database.close());
it('issues ten formatted codes and consumes one atomically', async () => {
const issued = await issueRecoveryCodes(database, {
userId: 'user-1',
now,
});
expect(issued.codes).toHaveLength(10);
expect(issued.codes[0]).toMatch(/^[0-9A-F]{4}(?:-[0-9A-F]{4}){7}$/);
expect(normalizeRecoveryCode(issued.codes[0])).toHaveLength(32);
expect(formatRecoveryCode(normalizeRecoveryCode(issued.codes[0])!)).toBe(
issued.codes[0],
);
expect(
await consumeRecoveryCode(database, {
userId: 'user-1',
code: issued.codes[0],
now,
}),
).toBe(true);
expect(
await consumeRecoveryCode(database, {
userId: 'user-1',
code: issued.codes[0],
now,
}),
).toBe(false);
});
it('revokes unused codes when regenerated', async () => {
await issueRecoveryCodes(database, { userId: 'user-1', now });
await issueRecoveryCodes(database, {
userId: 'user-1',
now: new Date(now.getTime() + 1_000),
});
const revoked = await (
await database.prepare(
'SELECT COUNT(*) AS count FROM mfa_recovery_codes WHERE revoked_at IS NOT NULL',
)
).get();
expect(Number(revoked.count)).toBe(10);
});
});
describe('authentication transactions', () => {
let database: Database;
beforeEach(async () => {
database = await transactionDatabase();
});
afterEach(async () => database.close());
it('enforces local return paths, five attempts, and one-time consumption', async () => {
expect(validateAuthenticationReturnPath('/builder?tab=weapons')).toBe(
'/builder?tab=weapons',
);
expect(
validateAuthenticationReturnPath('https://evil.example/'),
).toBeNull();
expect(validateAuthenticationReturnPath('//evil.example')).toBeNull();
expect(validateAuthenticationReturnPath('/%2F%2Fevil.example')).toBeNull();
const cookies = cookieJar();
const transaction = await createAuthenticationTransaction(
cookies as never,
database,
{
userId: 'user-1',
kind: 'login',
provider: 'password',
returnPath: '/builder',
now,
},
);
expect(cookies.set).toHaveBeenCalledWith(
'shd_auth_tx',
expect.any(String),
expect.objectContaining({
path: '/auth/two-factor',
httpOnly: true,
sameSite: 'lax',
maxAge: 300,
}),
);
expect(transaction.provider).toBe('password');
const stored = await (
await database.prepare(
'SELECT token_hash FROM authentication_transactions',
)
).get();
expect(String(stored.token_hash)).not.toBe(
cookies.values.get('shd_auth_tx'),
);
for (let attempt = 0; attempt < 5; attempt += 1) {
expect(
(await recordAuthenticationAttempt(cookies as never, database, now))
.accepted,
).toBe(true);
}
expect(
await getAuthenticationTransaction(cookies as never, database, now),
).toBeNull();
const secondCookies = cookieJar();
const second = await createAuthenticationTransaction(
secondCookies as never,
database,
{
userId: 'user-1',
kind: 'step_up',
returnPath: '/account/security',
now,
},
);
const consumed = await consumeAuthenticationTransaction(
secondCookies as never,
database,
now,
);
expect(consumed).toMatchObject({
id: second.id,
kind: 'step_up',
requiredAal: 2,
});
expect(
await consumeAuthenticationTransaction(
secondCookies as never,
database,
now,
),
).toBeNull();
});
});
+157
View File
@@ -0,0 +1,157 @@
import { getDatabase } from '$lib/server/database';
import type { Database } from '@tursodatabase/database';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
export const RECOVERY_CODE_COUNT = 10;
export const RECOVERY_CODE_BYTES = 16;
const RECOVERY_CODE_DOMAIN = 'shd.build:mfa-recovery-code:v1';
export type RecoveryDatabase = Pick<Database, 'prepare' | 'transactionAsync'>;
export type RecoveryTransaction = Pick<Database, 'prepare'>;
export class RecoveryCodeError extends Error {
constructor(message = 'Unable to process recovery codes.') {
super(message);
this.name = 'RecoveryCodeError';
}
}
export type IssuedRecoveryCodes = {
codes: string[];
createdAt: string;
};
function databaseOrDefault(
database?: RecoveryDatabase,
): Promise<RecoveryDatabase> {
return database
? Promise.resolve(database)
: (getDatabase() as Promise<RecoveryDatabase>);
}
function timestamp(value: Date | string | number | undefined): string {
const date =
value instanceof Date ? new Date(value) : new Date(value ?? Date.now());
if (!Number.isFinite(date.getTime())) throw new RecoveryCodeError();
return date.toISOString();
}
/** Return the canonical code used for hashing, or null for malformed input. */
export function normalizeRecoveryCode(input: string): string | null {
if (typeof input !== 'string') return null;
const normalized = input
.normalize('NFKC')
.replace(/[\s-]+/gu, '')
.toUpperCase();
return /^[0-9A-F]{32}$/.test(normalized) ? normalized : null;
}
/** Add display separators without changing the canonical validation format. */
export function formatRecoveryCode(canonicalCode: string): string {
const normalized = normalizeRecoveryCode(canonicalCode);
if (!normalized) throw new RecoveryCodeError();
return normalized.match(/.{1,4}/g)?.join('-') ?? normalized;
}
export function generateRecoveryCode(): { canonical: string; display: string } {
const canonical = randomBytes(RECOVERY_CODE_BYTES)
.toString('hex')
.toUpperCase();
return { canonical, display: formatRecoveryCode(canonical) };
}
export function hashRecoveryCode(userId: string, code: string): string {
const normalized = normalizeRecoveryCode(code);
if (!userId || !normalized) throw new RecoveryCodeError();
return createHash('sha256')
.update(RECOVERY_CODE_DOMAIN, 'utf8')
.update('\0', 'utf8')
.update(userId, 'utf8')
.update('\0', 'utf8')
.update(normalized, 'utf8')
.digest('hex');
}
export async function issueRecoveryCodesInTransaction(
transaction: RecoveryTransaction,
options: { userId: string; now?: Date | string | number },
): Promise<IssuedRecoveryCodes> {
if (!options.userId) throw new RecoveryCodeError();
const createdAt = timestamp(options.now);
const generated = Array.from(
{ length: RECOVERY_CODE_COUNT },
generateRecoveryCode,
);
await (
await transaction.prepare(
'UPDATE mfa_recovery_codes SET revoked_at=? WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL',
)
).run(createdAt, options.userId);
for (const code of generated) {
await (
await transaction.prepare(
'INSERT INTO mfa_recovery_codes(id,user_id,code_hash,created_at) VALUES(?,?,?,?)',
)
).run(
randomUUID(),
options.userId,
hashRecoveryCode(options.userId, code.canonical),
createdAt,
);
}
return { codes: generated.map((code) => code.display), createdAt };
}
export async function issueRecoveryCodes(
databaseInput: RecoveryDatabase | undefined,
options: { userId: string; now?: Date | string | number },
): Promise<IssuedRecoveryCodes> {
if (!options.userId) throw new RecoveryCodeError();
const database = await databaseOrDefault(databaseInput);
const issued = database.transactionAsync(async (transaction) => {
return issueRecoveryCodesInTransaction(transaction, options);
});
return await issued.immediate();
}
export const regenerateRecoveryCodes = issueRecoveryCodes;
export async function consumeRecoveryCode(
databaseInput: RecoveryDatabase | undefined,
options: { userId: string; code: string; now?: Date | string | number },
): Promise<boolean> {
const normalized = normalizeRecoveryCode(options.code);
if (!normalized || !options.userId) return false;
const database = await databaseOrDefault(databaseInput);
const consumed = database.transactionAsync((transaction) =>
consumeRecoveryCodeInTransaction(transaction, {
...options,
code: normalized,
}),
);
return await consumed.immediate();
}
/** Consume one recovery code using the caller's transaction. */
export async function consumeRecoveryCodeInTransaction(
transaction: RecoveryTransaction,
options: { userId: string; code: string; now?: Date | string | number },
): Promise<boolean> {
const normalized = normalizeRecoveryCode(options.code);
if (!normalized || !options.userId) return false;
const consumedAt = timestamp(options.now);
const result = await (
await transaction.prepare(
`UPDATE mfa_recovery_codes
SET consumed_at=?
WHERE user_id=? AND code_hash=? AND consumed_at IS NULL AND revoked_at IS NULL`,
)
).run(
consumedAt,
options.userId,
hashRecoveryCode(options.userId, normalized),
);
return result.changes === 1;
}
export const useRecoveryCode = consumeRecoveryCode;
+416
View File
@@ -0,0 +1,416 @@
import { getDatabase } from '$lib/server/database';
import type { Database } from '@tursodatabase/database';
import { randomBytes, randomUUID } from 'node:crypto';
import { Secret, TOTP } from 'otpauth';
import {
decryptTotpSecret,
encryptTotpSecret,
getMfaTotpEncryptionKeys,
MFA_TOTP_PARAMETERS,
rewrapTotpSecret,
} from './mfa-crypto';
export const TOTP_ISSUER = 'SHD.build';
export const TOTP_SECRET_BYTES = 20;
export const TOTP_WINDOW = 1;
export const TOTP_PENDING_SECONDS = 10 * 60;
export const TOTP_FAILURE_LIMIT = 5;
export const TOTP_LOCK_SECONDS = 5 * 60;
export type TotpDatabase = Pick<Database, 'prepare' | 'transactionAsync'>;
export type TotpTransaction = Pick<Database, 'prepare'>;
export class TotpServiceError extends Error {
constructor(message = 'Unable to process the TOTP factor.') {
super(message);
this.name = 'TotpServiceError';
}
}
export type TotpEnrollment = {
factorId: string;
secretBase32: string;
provisioningUri: string;
expiresAt: string;
replacedFactorId?: string;
};
export type TotpEnrollmentOptions = {
userId: string;
accountLabel?: string;
now?: Date | string | number;
replaceActive?: boolean;
};
export type TotpVerificationResult =
| {
status: 'verified';
counter: number;
needsRewrap: boolean;
keyId: string;
}
| {
status: 'invalid' | 'replayed' | 'expired';
lockedUntil: string | null;
}
| {
status: 'locked';
lockedUntil: string;
};
export type TotpVerificationOptions = {
userId: string;
factorId: string;
token: string;
now?: Date | string | number;
};
type TotpFactorRow = {
id: string;
user_id: string;
state: 'pending' | 'active' | 'revoked';
encrypted_secret: string;
encryption_key_id: string;
issuer: string;
account_label: string;
last_counter: number | null;
failed_attempts: number;
locked_until: string | null;
expires_at: string | null;
};
function databaseOrDefault(database?: TotpDatabase): Promise<TotpDatabase> {
return database
? Promise.resolve(database)
: (getDatabase() as Promise<TotpDatabase>);
}
function asTimestamp(value: Date | string | number | undefined): Date {
const result =
value instanceof Date
? new Date(value.getTime())
: new Date(value ?? Date.now());
if (!Number.isFinite(result.getTime())) throw new TotpServiceError();
return result;
}
function toSecret(secret: Uint8Array): Secret {
if (secret.length !== TOTP_SECRET_BYTES) throw new TotpServiceError();
const bytes = Uint8Array.from(secret);
return new Secret({ buffer: bytes.buffer as ArrayBuffer });
}
function totpFor(secret: Uint8Array, label: string): TOTP {
return new TOTP({
issuer: TOTP_ISSUER,
label,
issuerInLabel: true,
secret: toSecret(secret),
algorithm: MFA_TOTP_PARAMETERS.algorithm,
digits: MFA_TOTP_PARAMETERS.digits,
period: MFA_TOTP_PARAMETERS.periodSeconds,
});
}
export function generateTotpSecret(): Buffer {
return randomBytes(TOTP_SECRET_BYTES);
}
export function generateTotpCode(
secret: Uint8Array,
timestamp = Date.now(),
): string {
return totpFor(secret, TOTP_ISSUER).generate({ timestamp });
}
export function validateTotpCode(
secret: Uint8Array,
token: string,
timestamp = Date.now(),
): number | null {
if (!/^\d{6}$/.test(token)) return null;
return totpFor(secret, TOTP_ISSUER).validate({
token,
timestamp,
window: TOTP_WINDOW,
});
}
export function totpCounter(timestamp = Date.now()): number {
return TOTP.counter({
period: MFA_TOTP_PARAMETERS.periodSeconds,
timestamp,
});
}
function provisioningUri(secret: Uint8Array, label: string): string {
return totpFor(secret, label).toString();
}
export async function beginTotpEnrollmentInTransaction(
transaction: TotpTransaction,
options: TotpEnrollmentOptions,
): Promise<TotpEnrollment> {
if (!options.userId || options.userId.length > 200)
throw new TotpServiceError();
const now = asTimestamp(options.now);
const nowIso = now.toISOString();
const expiresAt = new Date(
now.getTime() + TOTP_PENDING_SECONDS * 1000,
).toISOString();
const factorId = randomUUID();
const accountLabel = (options.accountLabel ?? options.userId).trim();
if (!accountLabel || accountLabel.length > 200) throw new TotpServiceError();
const secret = generateTotpSecret();
const keys = getMfaTotpEncryptionKeys();
const encryptedSecret = encryptTotpSecret(
{ factorId, userId: options.userId, secret },
keys,
);
const current = (await (
await transaction.prepare(
"SELECT id,state,expires_at FROM mfa_totp_factors WHERE user_id=? AND state IN ('pending','active')",
)
).all(options.userId)) as Array<{
id: string;
state: 'pending' | 'active';
expires_at: string | null;
}>;
const pending = current.find((row) => row.state === 'pending');
if (pending && (!pending.expires_at || pending.expires_at <= nowIso)) {
await (
await transaction.prepare(
"UPDATE mfa_totp_factors SET state='revoked',revoked_at=? WHERE id=? AND user_id=? AND state='pending'",
)
).run(nowIso, pending.id, options.userId);
} else if (pending) {
throw new TotpServiceError();
}
const active = current.find((row) => row.state === 'active');
if (active && !options.replaceActive) throw new TotpServiceError();
await (
await transaction.prepare(
`INSERT INTO mfa_totp_factors(
id,user_id,state,encrypted_secret,encryption_key_id,issuer,account_label,
algorithm,digits,period_seconds,failed_attempts,expires_at,created_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
)
).run(
factorId,
options.userId,
'pending',
encryptedSecret,
keys[0].id,
TOTP_ISSUER,
accountLabel,
MFA_TOTP_PARAMETERS.algorithm,
MFA_TOTP_PARAMETERS.digits,
MFA_TOTP_PARAMETERS.periodSeconds,
0,
expiresAt,
nowIso,
);
return {
factorId,
secretBase32: toSecret(secret).base32,
provisioningUri: provisioningUri(secret, accountLabel),
expiresAt,
...(active ? { replacedFactorId: active.id } : {}),
};
}
export async function beginTotpEnrollment(
databaseInput: TotpDatabase | undefined,
options: TotpEnrollmentOptions,
): Promise<TotpEnrollment> {
const database = await databaseOrDefault(databaseInput);
const insert = database.transactionAsync(
async (transaction) =>
await beginTotpEnrollmentInTransaction(transaction, options),
);
return await insert.immediate();
}
export const startTotpEnrollment = beginTotpEnrollment;
function lockedUntilDate(row: TotpFactorRow, now: Date): string | null {
if (!row.locked_until) return null;
return new Date(row.locked_until) > now ? row.locked_until : null;
}
export async function verifyTotpFactorInTransaction(
transaction: TotpTransaction,
options: TotpVerificationOptions,
pendingOnly = false,
activatePending = true,
): Promise<TotpVerificationResult> {
const now = asTimestamp(options.now);
const nowIso = now.toISOString();
const lockUntil = new Date(
now.getTime() + TOTP_LOCK_SECONDS * 1000,
).toISOString();
const row = (await (
await transaction.prepare(
'SELECT id,user_id,state,encrypted_secret,encryption_key_id,issuer,account_label,last_counter,failed_attempts,locked_until,expires_at FROM mfa_totp_factors WHERE id=? AND user_id=? LIMIT 1',
)
).get(options.factorId, options.userId)) as TotpFactorRow | undefined;
if (
!row ||
row.state === 'revoked' ||
(pendingOnly && row.state !== 'pending')
)
return { status: 'invalid', lockedUntil: null } as const;
if (
row.state === 'pending' &&
(!row.expires_at || row.expires_at <= nowIso)
) {
await (
await transaction.prepare(
"UPDATE mfa_totp_factors SET state='revoked',revoked_at=? WHERE id=? AND state='pending'",
)
).run(nowIso, row.id);
return { status: 'expired', lockedUntil: null } as const;
}
const existingLock = lockedUntilDate(row, now);
if (existingLock)
return { status: 'locked', lockedUntil: existingLock } as const;
let decrypted;
try {
decrypted = decryptTotpSecret(
{
factorId: row.id,
userId: row.user_id,
envelope: row.encrypted_secret,
},
getMfaTotpEncryptionKeys(),
);
} catch {
throw new TotpServiceError();
}
const delta = validateTotpCode(
decrypted.secret,
options.token,
now.getTime(),
);
const currentCounter = totpCounter(now.getTime());
const matchedCounter = delta === null ? null : currentCounter + delta;
if (matchedCounter !== null) {
const update =
pendingOnly && !activatePending
? `UPDATE mfa_totp_factors
SET last_counter=?,failed_attempts=0,locked_until=NULL
WHERE id=? AND user_id=? AND state='pending'
AND (last_counter IS NULL OR last_counter<?)
AND (locked_until IS NULL OR locked_until<=?)`
: `UPDATE mfa_totp_factors
SET last_counter=?,failed_attempts=0,locked_until=NULL,
state=CASE WHEN state='pending' THEN 'active' ELSE state END,
expires_at=CASE WHEN state='pending' THEN NULL ELSE expires_at END,
activated_at=CASE WHEN state='pending' THEN ? ELSE activated_at END
WHERE id=? AND user_id=? AND state IN ('pending','active')
AND (last_counter IS NULL OR last_counter<?)
AND (locked_until IS NULL OR locked_until<=?)`;
const updated = await (
await transaction.prepare(update)
).run(
...(pendingOnly && !activatePending
? [matchedCounter, row.id, options.userId, matchedCounter, nowIso]
: [
matchedCounter,
nowIso,
row.id,
options.userId,
matchedCounter,
nowIso,
]),
);
if (updated.changes === 1) {
if (decrypted.needsRewrap) {
const rewrapped = rewrapTotpSecret(
{
factorId: row.id,
userId: row.user_id,
envelope: row.encrypted_secret,
},
getMfaTotpEncryptionKeys(),
);
const rewrappedRow = await (
await transaction.prepare(
`UPDATE mfa_totp_factors
SET encrypted_secret=?,encryption_key_id=?
WHERE id=? AND user_id=? AND state IN ('pending','active')`,
)
).run(
rewrapped.envelope,
rewrapped.activeKeyId,
row.id,
options.userId,
);
if (rewrappedRow.changes !== 1) throw new TotpServiceError();
}
return {
status: 'verified',
counter: matchedCounter,
needsRewrap: decrypted.needsRewrap,
keyId: decrypted.keyId,
} as const;
}
return { status: 'replayed', lockedUntil: null } as const;
}
const failedAttempts =
row.locked_until && row.locked_until <= nowIso
? 1
: row.failed_attempts + 1;
const locked = failedAttempts >= TOTP_FAILURE_LIMIT;
await (
await transaction.prepare(
`UPDATE mfa_totp_factors
SET failed_attempts=?,locked_until=?
WHERE id=? AND user_id=? AND state IN ('pending','active')
AND (locked_until IS NULL OR locked_until<=?)`,
)
).run(
failedAttempts,
locked ? lockUntil : null,
row.id,
options.userId,
nowIso,
);
return {
status: 'invalid',
lockedUntil: locked ? lockUntil : null,
} as const;
}
export async function verifyTotpFactor(
databaseInput: TotpDatabase | undefined,
options: TotpVerificationOptions,
): Promise<TotpVerificationResult> {
const database = await databaseOrDefault(databaseInput);
const verify = database.transactionAsync((transaction) =>
verifyTotpFactorInTransaction(transaction, options),
);
return await verify.immediate();
}
export const verifyTotp = verifyTotpFactor;
export async function revokeTotpFactor(
databaseInput: TotpDatabase | undefined,
options: { userId: string; factorId: string; now?: Date | string | number },
): Promise<boolean> {
const database = await databaseOrDefault(databaseInput);
const now = asTimestamp(options.now).toISOString();
const result = await (
await database.prepare(
"UPDATE mfa_totp_factors SET state='revoked',revoked_at=? WHERE id=? AND user_id=? AND state IN ('pending','active')",
)
).run(now, options.factorId, options.userId);
return result.changes === 1;
}
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
database: { prepare: vi.fn() },
hasActiveTotpFactor: vi.fn(),
isFullAdministrator: vi.fn(),
createAuthenticationTransaction: vi.fn(),
createSession: vi.fn(),
recordLogin: vi.fn(),
}));
vi.mock('./bootstrap', () => ({
hasActiveTotpFactor: mocks.hasActiveTotpFactor,
isFullAdministrator: mocks.isFullAdministrator,
}));
vi.mock('./authentication-transactions', () => ({
createAuthenticationTransaction: mocks.createAuthenticationTransaction,
}));
vi.mock('./session', () => ({
createSession: mocks.createSession,
recordLogin: mocks.recordLogin,
}));
import { completePrimaryAuthentication } from './primary-authentication';
const providers = ['password', 'magic_link', 'discord', 'passkey'] as const;
describe('primary authentication completion', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.database.prepare.mockResolvedValue({
get: vi.fn(async () => ({ id: 'user-1' })),
});
mocks.hasActiveTotpFactor.mockResolvedValue(true);
mocks.isFullAdministrator.mockResolvedValue(false);
mocks.createAuthenticationTransaction.mockResolvedValue({
id: 'tx-1',
userId: 'user-1',
provider: 'password',
kind: 'login',
returnPath: '/account',
requiredAal: 2,
attemptCount: 0,
expiresAt: '2026-08-07T12:05:00.000Z',
});
mocks.createSession.mockResolvedValue('session-1');
});
it.each(providers)(
'cannot bypass active MFA after %s verification',
async (provider) => {
const result = await completePrimaryAuthentication({
database: mocks.database as never,
cookies: {} as never,
userId: 'user-1',
provider,
returnPath: '/account',
});
expect(result).toMatchObject({
kind: 'mfa',
redirectTo: '/auth/two-factor',
});
expect(mocks.createSession).not.toHaveBeenCalled();
expect(mocks.createAuthenticationTransaction).toHaveBeenCalledWith(
{},
mocks.database,
expect.objectContaining({
userId: 'user-1',
kind: 'login',
provider,
}),
);
expect(mocks.recordLogin).toHaveBeenCalledWith(
'user-1',
provider,
false,
expect.objectContaining({
failureReason: 'mfa_required',
riskFlags: ['primary_authenticated_mfa_required'],
}),
);
},
);
it('gives an unenrolled administrator only an enrollment session', async () => {
mocks.hasActiveTotpFactor.mockResolvedValue(false);
mocks.isFullAdministrator.mockResolvedValue(true);
const result = await completePrimaryAuthentication({
database: mocks.database as never,
cookies: {} as never,
userId: 'user-1',
provider: 'password',
});
expect(result).toMatchObject({
kind: 'session',
aal: 1,
redirectTo: '/account/security?required=admin',
});
expect(mocks.createAuthenticationTransaction).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,120 @@
import type { Database } from '@tursodatabase/database';
import type { Cookies } from '@sveltejs/kit';
import { hasActiveTotpFactor, isFullAdministrator } from './bootstrap';
import {
createAuthenticationTransaction,
type AuthenticationTransaction,
} from './authentication-transactions';
import { createSession, recordLogin, type SessionAuthLevel } from './session';
export type PrimaryAuthenticationProvider =
'password' | 'discord' | 'magic_link' | 'passkey';
export class PrimaryAuthenticationError extends Error {
readonly code: 'account_restricted' | 'account_missing';
constructor(code: 'account_restricted' | 'account_missing') {
super('This account is not permitted to sign in.');
this.name = 'PrimaryAuthenticationError';
this.code = code;
}
}
export type PrimaryAuthenticationResult =
| {
kind: 'session';
sessionId: string;
aal: SessionAuthLevel;
redirectTo: string;
}
| {
kind: 'mfa';
transaction: AuthenticationTransaction;
redirectTo: '/auth/two-factor';
};
export type CompletePrimaryAuthenticationInput = {
database: Database;
cookies: Cookies;
userId: string;
provider: PrimaryAuthenticationProvider;
request?: Request;
getClientAddress?: () => string;
credentialId?: string | null;
returnPath?: string;
};
/**
* Completes a verified password, email, Discord, or passkey identity. This is
* the only primary-auth path allowed to create a session. Accounts with an
* active TOTP factor receive an opaque MFA transaction instead.
*/
export async function completePrimaryAuthentication(
input: CompletePrimaryAuthenticationInput,
): Promise<PrimaryAuthenticationResult> {
const status = await (
await input.database.prepare(`
SELECT users.id,user_status.blocked_at,user_status.banned_until,
deleted_accounts.deleted_at
FROM users
LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE users.id=?
`)
).get(input.userId);
const banned =
typeof status?.banned_until === 'string' &&
new Date(status.banned_until) > new Date();
if (!status) throw new PrimaryAuthenticationError('account_missing');
if (status.deleted_at || status.blocked_at || banned) {
await recordLogin(input.userId, input.provider, false, {
request: input.request,
getClientAddress: input.getClientAddress,
credentialId: input.credentialId,
failureReason: 'account_restricted',
});
throw new PrimaryAuthenticationError('account_restricted');
}
const activeFactor = await hasActiveTotpFactor(input.database, input.userId);
const fullAdministrator = await isFullAdministrator(
input.database,
input.userId,
);
if (!activeFactor) {
const sessionId = await createSession(input.cookies, input.userId, {
aal: 1,
});
const redirectTo = fullAdministrator
? '/account/security?required=admin'
: (input.returnPath ?? '/account');
await recordLogin(input.userId, input.provider, true, {
request: input.request,
getClientAddress: input.getClientAddress,
credentialId: input.credentialId,
sessionId,
aal: 1,
});
return { kind: 'session', sessionId, aal: 1, redirectTo };
}
const transaction = await createAuthenticationTransaction(
input.cookies,
input.database,
{
userId: input.userId,
kind: 'login',
provider: input.provider,
returnPath: input.returnPath ?? '/account',
},
);
await recordLogin(input.userId, input.provider, false, {
request: input.request,
getClientAddress: input.getClientAddress,
credentialId: input.credentialId,
failureReason: 'mfa_required',
riskFlags: ['primary_authenticated_mfa_required'],
aal: 1,
});
return { kind: 'mfa', transaction, redirectTo: '/auth/two-factor' };
}
+103
View File
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { connect, type Database } from '@tursodatabase/database';
const mocks = vi.hoisted(() => ({ database: null as Database | null }));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
vi.mock('$lib/avatar', () => ({
avatarUrl: vi.fn(() => '/missing-item-icon.svg'),
}));
import { createSession, getSessionUser } from './session';
function cookieJar() {
const values = new Map<string, string>();
return {
values,
get: (name: string) => values.get(name),
set: vi.fn((name: string, value: string) => values.set(name, value)),
delete: vi.fn((name: string) => values.delete(name)),
};
}
async function createDatabase(): Promise<Database> {
const database = await connect(':memory:');
await database.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY, username TEXT NOT NULL, login_username TEXT,
role TEXT NOT NULL, discord_id TEXT
);
CREATE TABLE user_profiles (
user_id TEXT PRIMARY KEY, display_name TEXT, public_slug TEXT
);
CREATE TABLE user_avatars (
user_id TEXT PRIMARY KEY, source TEXT, value TEXT, updated_at TEXT
);
CREATE TABLE user_status (
user_id TEXT PRIMARY KEY, last_active_at TEXT, blocked_at TEXT,
banned_until TEXT, updated_at TEXT NOT NULL DEFAULT ''
);
CREATE TABLE deleted_accounts (user_id TEXT PRIMARY KEY, deleted_at TEXT);
CREATE TABLE sessions (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL, created_at TEXT NOT NULL, aal INTEGER NOT NULL,
aal_verified_at TEXT NOT NULL, aal2_verified_at TEXT
);
INSERT INTO users(id,username,login_username,role,discord_id)
VALUES ('user-1','agent','agent','user',NULL);
INSERT INTO user_profiles(user_id,display_name,public_slug)
VALUES ('user-1','Agent','agent');
`);
return database;
}
describe('session AAL foundation', () => {
let database: Database;
beforeEach(async () => {
database = await createDatabase();
mocks.database = database;
});
afterEach(async () => {
mocks.database = null;
await database.close();
});
it('preserves legacy AAL1 calls and supports AAL2 metadata', async () => {
const aal2Cookies = cookieJar();
await createSession(aal2Cookies as never, 'user-1', { aal: 2 });
const row = await (
await database.prepare(
'SELECT token_hash,aal,aal_verified_at,aal2_verified_at FROM sessions',
)
).get();
expect(row.aal).toBe(2);
expect(row.aal_verified_at).toBeTruthy();
expect(row.aal2_verified_at).toBeTruthy();
expect(String(row.token_hash)).not.toBe(
aal2Cookies.values.get('shd_session'),
);
const user = await getSessionUser(aal2Cookies as never);
expect(user).toMatchObject({
id: 'user-1',
aal: 2,
authLevel: 2,
aalVerifiedAt: expect.any(String),
aal2VerifiedAt: expect.any(String),
});
const aal1Cookies = cookieJar();
await createSession(aal1Cookies as never, 'user-1');
const aal1 = await (
await database.prepare(
'SELECT aal,aal2_verified_at FROM sessions ORDER BY created_at DESC LIMIT 1',
)
).get();
expect(aal1.aal).toBe(1);
expect(aal1.aal2_verified_at).toBeNull();
});
});
+138 -19
View File
@@ -1,5 +1,6 @@
import { dev } from '$app/environment';
import { getDatabase } from '$lib/server/database';
import type { Database } from '@tursodatabase/database';
import type { Cookies } from '@sveltejs/kit';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { avatarUrl } from '$lib/avatar';
@@ -16,6 +17,27 @@ export type AuthUser = {
role: string;
discordLinked: boolean;
avatarUrl: string;
/** Server-only authentication assurance metadata; no raw token is exposed. */
aal: 1 | 2;
authLevel: 1 | 2;
sessionCreatedAt: string;
aalVerifiedAt: string;
aal2VerifiedAt: string | null;
};
export type SessionAuthLevel = 1 | 2;
export type SessionTransaction = Pick<Database, 'prepare'>;
export type PendingSession = {
sessionId: string;
token: string;
};
export type CreateSessionOptions = {
aal?: SessionAuthLevel;
authLevel?: SessionAuthLevel;
aalVerifiedAt?: Date | string;
aal2VerifiedAt?: Date | string | null;
};
type LoginRequestContext = {
@@ -25,23 +47,43 @@ type LoginRequestContext = {
credentialId?: string | null;
sessionId?: string | null;
riskFlags?: string[] | null;
aal?: SessionAuthLevel;
aal2VerifiedAt?: string | null;
};
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export async function createSession(
cookies: Cookies,
function sessionCookieOptions() {
return {
path: '/',
httpOnly: true,
sameSite: 'lax' as const,
secure: !dev,
maxAge: SESSION_SECONDS,
};
}
export function setSessionCookie(cookies: Cookies, token: string): void {
cookies.set(COOKIE_NAME, token, sessionCookieOptions());
}
export async function createSessionInTransaction(
transaction: SessionTransaction,
userId: string,
): Promise<string> {
const database = await getDatabase();
options: CreateSessionOptions = {},
): Promise<PendingSession> {
const token = randomBytes(32).toString('base64url');
const sessionId = randomUUID();
const now = new Date();
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
const insert = await database.prepare(
'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)',
const aal = options.authLevel ?? options.aal ?? 1;
const aalVerifiedAt = new Date(options.aalVerifiedAt ?? now).toISOString();
const aal2VerifiedAt =
aal === 2 ? new Date(options.aal2VerifiedAt ?? now).toISOString() : null;
const insert = await transaction.prepare(
'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at, aal, aal_verified_at, aal2_verified_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
);
await insert.run(
@@ -50,16 +92,67 @@ export async function createSession(
hashToken(token),
expiresAt.toISOString(),
now.toISOString(),
aal,
aalVerifiedAt,
aal2VerifiedAt,
);
cookies.set(COOKIE_NAME, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
maxAge: SESSION_SECONDS,
});
return sessionId;
return { sessionId, token };
}
export async function createSession(
cookies: Cookies,
userId: string,
options: CreateSessionOptions = {},
): Promise<string> {
const database = await getDatabase();
const operation = database.transactionAsync((transaction) =>
createSessionInTransaction(transaction, userId, options),
);
const session = await operation.immediate();
setSessionCookie(cookies, session.token);
return session.sessionId;
}
/** Upgrade the current cookie-backed session without exposing or reissuing its token. */
export async function upgradeSessionToAal2InTransaction(
transaction: SessionTransaction,
cookies: Cookies,
userId: string,
verifiedAt = new Date(),
): Promise<string | null> {
const token = cookies.get(COOKIE_NAME);
if (!token) return null;
const result = await (
await transaction.prepare(`
UPDATE sessions
SET aal=2,aal_verified_at=?,aal2_verified_at=?
WHERE token_hash=? AND user_id=? AND expires_at>?
`)
).run(
verifiedAt.toISOString(),
verifiedAt.toISOString(),
hashToken(token),
userId,
new Date().toISOString(),
);
if (result.changes !== 1) return null;
const row = await (
await transaction.prepare('SELECT id FROM sessions WHERE token_hash=?')
).get(hashToken(token));
return row?.id ? String(row.id) : null;
}
export async function upgradeSessionToAal2(
cookies: Cookies,
userId: string,
verifiedAt = new Date(),
): Promise<string | null> {
const database = await getDatabase();
const operation = database.transactionAsync((transaction) =>
upgradeSessionToAal2InTransaction(transaction, cookies, userId, verifiedAt),
);
return await operation.immediate();
}
function describeClient(userAgent: string) {
@@ -110,6 +203,23 @@ export async function recordLogin(
context: LoginRequestContext = {},
): Promise<void> {
const database = await getDatabase();
await recordLoginInTransaction(
database,
userId,
provider,
succeeded,
context,
);
await purgeExpiredLoginMetadata();
}
export async function recordLoginInTransaction(
transaction: SessionTransaction,
userId: string | null,
provider: 'password' | 'discord' | 'magic_link' | 'passkey',
succeeded: boolean,
context: LoginRequestContext = {},
): Promise<void> {
const userAgent =
context.request?.headers.get('user-agent')?.slice(0, 1000) ?? null;
const headers = context.request?.headers;
@@ -134,10 +244,10 @@ export async function recordLogin(
/* unavailable in some development adapters */
}
await (
await database.prepare(`INSERT INTO login_events(id,user_id,provider,succeeded,ip_address,user_agent,
await transaction.prepare(`INSERT INTO login_events(id,user_id,provider,succeeded,ip_address,user_agent,
client_name,operating_system,device_type,location_country,location_region,location_city,
failure_reason,risk_flags,credential_id,session_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
failure_reason,risk_flags,credential_id,session_id,aal,aal2_verified_at,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
randomUUID(),
userId,
@@ -155,9 +265,10 @@ export async function recordLogin(
riskFlags.length ? riskFlags.join(',') : null,
context.credentialId ?? null,
context.sessionId ?? null,
context.aal ?? (succeeded ? 1 : null),
context.aal2VerifiedAt ?? null,
new Date().toISOString(),
);
await purgeExpiredLoginMetadata();
}
export async function getSessionUser(
@@ -173,7 +284,9 @@ export async function getSessionUser(
users.role, users.discord_id, user_avatars.source AS avatar_source,
user_avatars.value AS avatar_value, user_avatars.updated_at AS avatar_updated_at,
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
deleted_accounts.deleted_at
deleted_accounts.deleted_at,
sessions.aal,sessions.aal_verified_at,sessions.aal2_verified_at,
sessions.created_at AS session_created_at
FROM sessions
JOIN users ON users.id = sessions.user_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
@@ -224,6 +337,12 @@ export async function getSessionUser(
String(user.id),
user.avatar_updated_at,
),
aal: Number(user.aal) === 2 ? 2 : 1,
authLevel: Number(user.aal) === 2 ? 2 : 1,
sessionCreatedAt: String(user.session_created_at),
aalVerifiedAt: String(user.aal_verified_at),
aal2VerifiedAt:
typeof user.aal2_verified_at === 'string' ? user.aal2_verified_at : null,
};
}
+43
View File
@@ -16,6 +16,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
talentEffects,
talentTemplates,
chipSets,
abilitySkillChipLinks,
chipTwoPiece,
chipPrimaryOptions,
chipPrimaryQualityValues,
@@ -31,6 +32,8 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
protocolTalentEffects,
specializations,
specializationFocuses,
specializationAbilities,
focusAbilities,
specializationNodes,
specializationRanks,
specializationRankEffects,
@@ -85,6 +88,14 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
'SELECT * FROM skill_chip_sets ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
`SELECT link.ability_id,link.compatible_ability_id
FROM specialization_ability_skill_chip_links link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
)
).all(),
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
@@ -136,6 +147,16 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
'SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_abilities ORDER BY specialization_id,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number',
@@ -194,6 +215,16 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
);
return {
...set,
compatibleAbilityIds: [
String(set.specialization_ability_id),
...abilitySkillChipLinks.flatMap((link) =>
link.ability_id === set.specialization_ability_id
? [String(link.compatible_ability_id)]
: link.compatible_ability_id === set.specialization_ability_id
? [String(link.ability_id)]
: [],
),
],
twoPiece:
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
null,
@@ -242,6 +273,18 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
.filter((focus) => focus.specialization_id === specialization.id)
.map((focus) => ({
...focus,
abilities: focusAbilities
.filter((link) => link.focus_id === focus.id)
.sort(
(left, right) =>
Number(left.ability_slot) - Number(right.ability_slot),
)
.map((link) =>
specializationAbilities.find(
(ability) => ability.id === link.ability_id,
),
)
.filter(Boolean),
nodes: specializationNodes
.filter((node) => node.focus_id === focus.id)
.map((node) => ({
+1 -1
View File
@@ -59,7 +59,7 @@ export async function loadSharedBuildProjection(
).all(),
(
await database.prepare(
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons',
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type,exotic_talent_id FROM weapons',
)
).all(),
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
+24 -13
View File
@@ -1,5 +1,8 @@
import { createSession, getSessionUser } from '$lib/server/auth/session';
import { softDeleteAccount } from '$lib/server/auth/account-deletion';
import {
LastAdministratorDeletionError,
softDeleteAccount,
} from '$lib/server/auth/account-deletion';
import { hashPassword, verifyPassword } from '$lib/server/auth/password';
import { getDatabase } from '$lib/server/database';
import { fail, redirect } from '@sveltejs/kit';
@@ -472,18 +475,26 @@ export const actions: Actions = {
) {
return fail(400, { deleteError: 'Your password is incorrect.' });
}
if (
!(await softDeleteAccount(
database,
user.id,
user.id,
'Deleted by account owner',
purgeSecurityData,
))
) {
return fail(409, {
deleteError: 'This account has already been deleted.',
});
try {
if (
!(await softDeleteAccount(
database,
user.id,
user.id,
'Deleted by account owner',
purgeSecurityData,
))
)
return fail(409, {
deleteError: 'This account has already been deleted.',
});
} catch (error) {
if (error instanceof LastAdministratorDeletionError)
return fail(409, {
deleteError:
'The final active full administrator cannot delete this account.',
});
throw error;
}
cookies.delete('shd_session', { path: '/' });
redirect(303, '/?account_deleted=1');
+803
View File
@@ -0,0 +1,803 @@
import { dev } from '$app/environment';
import { fail, redirect } from '@sveltejs/kit';
import type { ActionFailure } from '@sveltejs/kit';
import type { PageServerLoad, RequestEvent } from './$types';
import { randomBytes, randomUUID, createHash } from 'node:crypto';
import type { Database } from '@tursodatabase/database';
import { getDatabase } from '$lib/server/database';
import { isFullAdministrator } from '$lib/server/auth/bootstrap';
import { getSessionUser } from '$lib/server/auth/session';
import {
beginTotpEnrollmentInTransaction,
verifyTotpFactorInTransaction,
TotpServiceError,
} from '$lib/server/auth/mfa-totp';
import {
issueRecoveryCodesInTransaction,
RecoveryCodeError,
} from '$lib/server/auth/mfa-recovery';
import { recordIsacAudit } from '$lib/server/audit';
const RECENT_AUTH_SECONDS = 10 * 60;
const SESSION_SECONDS = 30 * 24 * 60 * 60;
type MfaFailure = {
securityError: string;
mfaError: string;
mfaResultToken: string;
resultToken: string;
recoveryCodes: null;
};
type FactorState = 'pending' | 'active' | 'revoked';
type FactorRow = {
id: string;
state: FactorState;
created_at: string;
activated_at: string | null;
expires_at: string | null;
};
function resultToken() {
return randomUUID();
}
function failure(
status: number,
key: string,
message: string,
): ActionFailure<MfaFailure> {
const token = resultToken();
return fail(status, {
securityError: message,
[key]: message,
mfaError: message,
mfaResultToken: token,
resultToken: token,
recoveryCodes: null,
});
}
function isRecent(value: string | null | undefined, now: Date): boolean {
if (!value) return false;
const timestamp = Date.parse(value);
return (
Number.isFinite(timestamp) &&
timestamp <= now.getTime() &&
now.getTime() - timestamp <= RECENT_AUTH_SECONDS * 1000
);
}
function validIdentifier(value: FormDataEntryValue | null): string | null {
if (typeof value !== 'string') return null;
const normalized = value.normalize('NFKC').trim();
return normalized.length > 0 && normalized.length <= 200 ? normalized : null;
}
function hashSessionToken(token: string): string {
return createHash('sha256').update(token, 'utf8').digest('hex');
}
function noStore(setHeaders?: RequestEvent['setHeaders']): void {
setHeaders?.({
'cache-control': 'no-store',
'referrer-policy': 'no-referrer',
});
}
function setAal2SessionCookie(cookies: RequestEvent['cookies'], token: string) {
cookies.set('shd_session', token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
maxAge: SESSION_SECONDS,
});
}
async function authenticated(cookies: RequestEvent['cookies']) {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
return user;
}
async function currentFactors(userId: string, now: Date) {
const database = await getDatabase();
const rows = (await (
await database.prepare(
`SELECT id,state,created_at,activated_at,expires_at
FROM mfa_totp_factors
WHERE user_id=? AND (state='active' OR (state='pending' AND expires_at>?))
ORDER BY created_at DESC`,
)
).all(userId, now.toISOString())) as FactorRow[];
return { database, rows };
}
function assuranceFailure(
user: Awaited<ReturnType<typeof authenticated>>,
now: Date,
) {
if (user.aal === 2 && isRecent(user.aal2VerifiedAt, now)) return null;
if (isRecent(user.aalVerifiedAt, now)) return null;
return failure(
403,
'securityError',
'Recent authentication is required before changing TOTP security settings.',
);
}
function recentAal2Failure(
user: Awaited<ReturnType<typeof authenticated>>,
now: Date,
) {
if (user.aal === 2 && isRecent(user.aal2VerifiedAt, now)) return null;
return failure(
403,
'securityError',
'Recent two-factor authentication is required for this operation.',
);
}
export const load: PageServerLoad = async ({ cookies, setHeaders }) => {
noStore(setHeaders);
const user = await authenticated(cookies);
const now = new Date();
const { database, rows } = await currentFactors(user.id, now);
const required = await isFullAdministrator(database, user.id);
const active = rows.find((row) => row.state === 'active') ?? null;
const pending = rows.find((row) => row.state === 'pending') ?? null;
const recovery = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM mfa_recovery_codes
WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL`,
)
).get(user.id);
return {
requiredByAdministratorRole: required,
enabled: Boolean(active),
activeFactorId: active ? String(active.id) : null,
activeFactorCreatedAt: active ? String(active.created_at) : null,
activeFactorActivatedAt: active?.activated_at
? String(active.activated_at)
: null,
pendingFactorId: pending ? String(pending.id) : null,
pendingFactorCreatedAt: pending ? String(pending.created_at) : null,
pendingFactorExpiresAt: pending?.expires_at
? String(pending.expires_at)
: null,
pendingFactor: pending
? {
id: String(pending.id),
factorId: String(pending.id),
expiresAt: pending.expires_at ? String(pending.expires_at) : null,
}
: null,
activeFactor: active
? {
id: String(active.id),
factorId: String(active.id),
enabledAt: active.activated_at ? String(active.activated_at) : null,
}
: null,
lastAssuranceAt: user.aal2VerifiedAt ?? user.aalVerifiedAt,
recoveryCodeCount: Number(recovery?.count ?? 0),
recoveryCodesRemaining: Number(recovery?.count ?? 0),
totp: {
required,
requirement: required ? ('required' as const) : ('optional' as const),
activeFactor: active
? {
id: String(active.id),
createdAt: String(active.created_at),
activatedAt: active.activated_at
? String(active.activated_at)
: null,
}
: null,
pendingFactor: pending
? {
id: String(pending.id),
createdAt: String(pending.created_at),
expiresAt: pending.expires_at ? String(pending.expires_at) : null,
}
: null,
recoveryCodesRemaining: Number(recovery?.count ?? 0),
},
sessionAssurance: {
aal: user.aal,
sessionCreatedAt: user.sessionCreatedAt,
aalVerifiedAt: user.aalVerifiedAt,
aal2VerifiedAt: user.aal2VerifiedAt,
},
};
};
const beginEnrollment = async ({
cookies,
request,
setHeaders,
}: RequestEvent) => {
noStore(setHeaders);
const user = await authenticated(cookies);
const now = new Date();
const { database, rows } = await currentFactors(user.id, now);
const active = rows.find((row) => row.state === 'active');
const pending = rows.find((row) => row.state === 'pending');
if (pending)
return failure(
409,
'securityError',
'An enrollment is already waiting for confirmation.',
);
if (active) {
const recent = recentAal2Failure(user, now);
if (recent) return recent;
} else {
const recent = assuranceFailure(user, now);
if (recent) return recent;
}
const form = await request.formData();
const accountLabel = String(
form.get('accountLabel') ?? user.loginUsername ?? user.username,
)
.normalize('NFKC')
.trim();
try {
const operation = database.transactionAsync(async (transaction) => {
const enrollment = await beginTotpEnrollmentInTransaction(transaction, {
userId: user.id,
accountLabel,
now,
replaceActive: Boolean(active),
});
await recordIsacAudit({
actorId: user.id,
category: 'identity',
action: 'totp.enrollment_started',
entityId: user.id,
after: {
factorId: enrollment.factorId,
replacedFactorId: enrollment.replacedFactorId ?? null,
expiresAt: enrollment.expiresAt,
algorithm: 'SHA1',
digits: 6,
periodSeconds: 30,
},
database: transaction as unknown as Database,
});
return enrollment;
});
const enrollment = await operation.immediate();
const token = resultToken();
return {
enrollmentStarted: true,
factorId: enrollment.factorId,
expiresAt: enrollment.expiresAt,
secretBase32: enrollment.secretBase32,
manualKey: enrollment.secretBase32,
base32Key: enrollment.secretBase32,
provisioningUri: enrollment.provisioningUri,
otpauthUri: enrollment.provisioningUri,
replacedFactorId: enrollment.replacedFactorId ?? null,
resultToken: token,
mfaResultToken: token,
mfaError: null,
recoveryCodes: null,
};
} catch (error) {
if (error instanceof TotpServiceError)
return failure(
409,
'securityError',
'TOTP enrollment could not be started. Try again after refreshing the page.',
);
return failure(
500,
'securityError',
'TOTP enrollment could not be started. Try again later.',
);
}
};
const confirmEnrollment = async ({
cookies,
request,
setHeaders,
}: RequestEvent) => {
noStore(setHeaders);
const user = await authenticated(cookies);
const form = await request.formData();
const factorId = validIdentifier(
form.get('factorId') ?? form.get('pendingFactorId'),
);
const token = String(form.get('code') ?? form.get('token') ?? '')
.normalize('NFKC')
.trim();
if (!factorId || !/^\d{6}$/.test(token))
return failure(
400,
'securityError',
'Enter the six-digit authenticator code.',
);
const now = new Date();
const { database } = await currentFactors(user.id, now);
const pending = await (
await database.prepare(
`SELECT id,created_at FROM mfa_totp_factors
WHERE id=? AND user_id=? AND state='pending' LIMIT 1`,
)
).get(factorId, user.id);
if (!pending)
return failure(
409,
'securityError',
'That TOTP enrollment is no longer available.',
);
const activeFactor = await (
await database.prepare(
`SELECT id FROM mfa_totp_factors
WHERE user_id=? AND state='active' LIMIT 1`,
)
).get(user.id);
const assurance = activeFactor
? recentAal2Failure(user, now)
: assuranceFailure(user, now);
if (assurance) return assurance;
const sessionToken = cookies.get('shd_session');
if (!sessionToken)
return failure(
401,
'securityError',
'Your session has expired. Sign in again.',
);
try {
const operation = database.transactionAsync(async (transaction) => {
const currentSession = await (
await transaction.prepare(
'SELECT id FROM sessions WHERE token_hash=? AND user_id=? LIMIT 1',
)
).get(hashSessionToken(sessionToken), user.id);
if (!currentSession) return { kind: 'session_missing' as const };
const currentPending = await (
await transaction.prepare(
"SELECT id,state FROM mfa_totp_factors WHERE id=? AND user_id=? AND state='pending' LIMIT 1",
)
).get(factorId, user.id);
if (!currentPending) return { kind: 'factor_missing' as const };
const verification = await verifyTotpFactorInTransaction(
transaction,
{ userId: user.id, factorId, token, now },
true,
false,
);
if (verification.status !== 'verified')
return { kind: 'verification_failed' as const, verification };
const previousActive = (await (
await transaction.prepare(
"SELECT id FROM mfa_totp_factors WHERE user_id=? AND state='active' AND id<>?",
)
).all(user.id, factorId)) as Array<{ id: string }>;
if (previousActive.length) {
await (
await transaction.prepare(
"UPDATE mfa_totp_factors SET state='revoked',revoked_at=? WHERE user_id=? AND state='active' AND id<>?",
)
).run(now.toISOString(), user.id, factorId);
}
const activated = await (
await transaction.prepare(
`UPDATE mfa_totp_factors
SET state='active',expires_at=NULL,activated_at=?
WHERE id=? AND user_id=? AND state='pending'`,
)
).run(now.toISOString(), factorId, user.id);
if (activated.changes !== 1) throw new TotpServiceError();
const issued = await issueRecoveryCodesInTransaction(transaction, {
userId: user.id,
now,
});
const nextToken = randomBytes(32).toString('base64url');
const nextSessionId = randomUUID();
const sessionCreatedAt = now.toISOString();
const sessionExpiresAt = new Date(
now.getTime() + SESSION_SECONDS * 1000,
).toISOString();
await (
await transaction.prepare(
`INSERT INTO sessions(
id,user_id,token_hash,expires_at,created_at,aal,aal_verified_at,aal2_verified_at
) VALUES(?,?,?,?,?,?,?,?)`,
)
).run(
nextSessionId,
user.id,
hashSessionToken(nextToken),
sessionExpiresAt,
sessionCreatedAt,
2,
user.aalVerifiedAt,
sessionCreatedAt,
);
const revokedSessions = await (
await transaction.prepare(
'DELETE FROM sessions WHERE user_id=? AND id<>?',
)
).run(user.id, nextSessionId);
await recordIsacAudit({
actorId: user.id,
category: 'identity',
action: 'totp.enrollment_confirmed',
entityId: user.id,
before: {
pendingFactorId: factorId,
previousActiveFactorIds: previousActive.map((row) => String(row.id)),
},
after: {
activeFactorId: factorId,
recoveryCodesIssued: issued.codes.length,
sessionsRevoked: Number(revokedSessions.changes ?? 0),
aal: 2,
algorithm: 'SHA1',
digits: 6,
periodSeconds: 30,
},
database: transaction as unknown as Database,
});
return {
kind: 'success' as const,
nextToken,
previousActiveFactorIds: previousActive.map((row) => String(row.id)),
recoveryCodes: issued.codes,
revokedSessionCount: Number(revokedSessions.changes ?? 0),
};
});
const result = await operation.immediate();
if (result.kind === 'session_missing')
return failure(
401,
'securityError',
'Your session has expired. Sign in again.',
);
if (result.kind === 'factor_missing')
return failure(
409,
'securityError',
'That TOTP enrollment is no longer available.',
);
if (result.kind === 'verification_failed') {
const message =
result.verification.status === 'locked'
? 'Too many invalid codes. Try again after the lock expires.'
: result.verification.status === 'expired'
? 'That TOTP enrollment has expired.'
: 'That authenticator code is invalid or has already been used.';
return failure(400, 'securityError', message);
}
setAal2SessionCookie(cookies, result.nextToken);
const responseToken = resultToken();
return {
enrollmentConfirmed: true,
factorId,
recoveryCodes: result.recoveryCodes,
recoveryCodesIssued: result.recoveryCodes.length,
resultToken: responseToken,
mfaResultToken: responseToken,
mfaError: null,
};
} catch (error) {
if (error instanceof TotpServiceError || error instanceof RecoveryCodeError)
return failure(
409,
'securityError',
'TOTP enrollment could not be confirmed.',
);
return failure(
500,
'securityError',
'TOTP enrollment could not be confirmed. Try again later.',
);
}
};
const cancelEnrollment = async ({
cookies,
request,
setHeaders,
}: RequestEvent) => {
noStore(setHeaders);
const user = await authenticated(cookies);
const cancelForm = await request.formData();
const requestedFactorId = validIdentifier(
cancelForm.get('factorId') ?? cancelForm.get('pendingFactorId'),
);
const database = await getDatabase();
const now = new Date().toISOString();
const pending = await (
await database.prepare(
requestedFactorId
? "SELECT id,created_at,expires_at FROM mfa_totp_factors WHERE id=? AND user_id=? AND state='pending' LIMIT 1"
: "SELECT id,created_at,expires_at FROM mfa_totp_factors WHERE user_id=? AND state='pending' LIMIT 1",
)
).get(...(requestedFactorId ? [requestedFactorId, user.id] : [user.id]));
if (!pending)
return failure(
409,
'securityError',
'That TOTP enrollment is no longer available.',
);
const expired =
typeof pending.expires_at !== 'string' || pending.expires_at <= now;
let result: boolean | null;
try {
const cancellation = database.transactionAsync(async (transaction) => {
const current = await (
await transaction.prepare(
"SELECT id FROM mfa_totp_factors WHERE id=? AND user_id=? AND state='pending' LIMIT 1",
)
).get(String(pending.id), user.id);
if (!current) return null;
const updated = await (
await transaction.prepare(
"UPDATE mfa_totp_factors SET state='revoked',revoked_at=? WHERE id=? AND user_id=? AND state='pending'",
)
).run(now, String(pending.id), user.id);
if (updated.changes !== 1) return null;
await recordIsacAudit({
actorId: user.id,
category: 'identity',
action: 'totp.enrollment_cancelled',
entityId: user.id,
before: { pendingFactorId: String(pending.id) },
after: {
state: 'revoked',
factorId: String(pending.id),
activeFactorRetained: true,
},
database: transaction as unknown as Database,
});
return true;
});
result = await cancellation.immediate();
} catch {
return failure(
500,
'securityError',
'TOTP enrollment could not be cancelled. Try again later.',
);
}
if (!result)
return failure(
409,
'securityError',
'That TOTP enrollment is no longer available.',
);
if (expired)
return failure(409, 'securityError', 'That TOTP enrollment has expired.');
const responseToken = resultToken();
return {
enrollmentCancelled: true,
factorId: String(pending.id),
resultToken: responseToken,
mfaResultToken: responseToken,
mfaError: null,
recoveryCodes: null,
};
};
const regenerateRecoveryCodes = async ({
cookies,
setHeaders,
}: RequestEvent) => {
noStore(setHeaders);
const user = await authenticated(cookies);
const now = new Date();
const recent = recentAal2Failure(user, now);
if (recent) return recent;
const { database } = await currentFactors(user.id, now);
const active = await (
await database.prepare(
"SELECT id FROM mfa_totp_factors WHERE user_id=? AND state='active' LIMIT 1",
)
).get(user.id);
if (!active)
return failure(
409,
'securityError',
'Enable TOTP before regenerating recovery codes.',
);
try {
const operation = database.transactionAsync(async (transaction) => {
const current = await (
await transaction.prepare(
"SELECT id FROM mfa_totp_factors WHERE user_id=? AND state='active' LIMIT 1",
)
).get(user.id);
if (!current) return null;
const previous = await (
await transaction.prepare(
`SELECT COUNT(*) AS count FROM mfa_recovery_codes
WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL`,
)
).get(user.id);
const issued = await issueRecoveryCodesInTransaction(transaction, {
userId: user.id,
now,
});
await recordIsacAudit({
actorId: user.id,
category: 'identity',
action: 'totp.recovery_codes_regenerated',
entityId: user.id,
before: {
factorId: String(current.id),
unusedRecoveryCodes: Number(previous?.count ?? 0),
},
after: {
factorId: String(current.id),
previousCodesRevoked: Number(previous?.count ?? 0),
recoveryCodesIssued: issued.codes.length,
},
database: transaction as unknown as Database,
});
return {
factorId: String(current.id),
previousCount: Number(previous?.count ?? 0),
codes: issued.codes,
};
});
const result = await operation.immediate();
if (!result)
return failure(
409,
'securityError',
'Enable TOTP before regenerating recovery codes.',
);
const responseToken = resultToken();
return {
recoveryCodesRegenerated: true,
recoveryCodes: result.codes,
recoveryCodesIssued: result.codes.length,
resultToken: responseToken,
mfaResultToken: responseToken,
mfaError: null,
};
} catch (error) {
if (error instanceof RecoveryCodeError)
return failure(
409,
'securityError',
'Recovery codes could not be regenerated.',
);
return failure(
500,
'securityError',
'Recovery codes could not be regenerated. Try again later.',
);
}
};
const disableTotp = async ({ cookies, request, setHeaders }: RequestEvent) => {
noStore(setHeaders);
const user = await authenticated(cookies);
const now = new Date();
const recent = recentAal2Failure(user, now);
if (recent) return recent;
const database = await getDatabase();
if (await isFullAdministrator(database, user.id))
return failure(
403,
'securityError',
'Full administrators must keep TOTP enabled.',
);
const disableForm = await request.formData();
const requestedFactorId = validIdentifier(
disableForm.get('factorId') ?? disableForm.get('activeFactorId'),
);
try {
const operation = database.transactionAsync(async (transaction) => {
const active = await (
await transaction.prepare(
requestedFactorId
? "SELECT id FROM mfa_totp_factors WHERE id=? AND user_id=? AND state='active' LIMIT 1"
: "SELECT id FROM mfa_totp_factors WHERE user_id=? AND state='active' LIMIT 1",
)
).get(...(requestedFactorId ? [requestedFactorId, user.id] : [user.id]));
if (!active) return null;
const factorId = String(active.id);
const factors = (await (
await transaction.prepare(
"SELECT id FROM mfa_totp_factors WHERE user_id=? AND state IN ('pending','active')",
)
).all(user.id)) as Array<{ id: string }>;
const revokedAt = now.toISOString();
await (
await transaction.prepare(
"UPDATE mfa_totp_factors SET state='revoked',revoked_at=? WHERE user_id=? AND state IN ('pending','active')",
)
).run(revokedAt, user.id);
const recovery = await (
await transaction.prepare(
'UPDATE mfa_recovery_codes SET revoked_at=? WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL',
)
).run(revokedAt, user.id);
const transactions = await (
await transaction.prepare(
'DELETE FROM authentication_transactions WHERE user_id=? AND consumed_at IS NULL',
)
).run(user.id);
const sessions = await (
await transaction.prepare('DELETE FROM sessions WHERE user_id=?')
).run(user.id);
await recordIsacAudit({
actorId: user.id,
category: 'identity',
action: 'totp.disabled',
entityId: user.id,
before: {
activeFactorId: factorId,
factorIds: factors.map((factor) => String(factor.id)),
},
after: {
factorsRevoked: factors.length,
recoveryCodesRevoked: Number(recovery.changes ?? 0),
authenticationTransactionsRevoked: Number(transactions.changes ?? 0),
sessionsRevoked: Number(sessions.changes ?? 0),
},
database: transaction as unknown as Database,
});
return {
activeFactorId: factorId,
factorIds: factors.map((factor) => String(factor.id)),
recoveryCodesRevoked: Number(recovery.changes ?? 0),
transactionsRevoked: Number(transactions.changes ?? 0),
sessionsRevoked: Number(sessions.changes ?? 0),
};
});
const result = await operation.immediate();
if (!result)
return failure(
409,
'securityError',
'That TOTP factor is no longer available.',
);
cookies.delete('shd_session', { path: '/' });
const responseToken = resultToken();
return {
totpDisabled: true,
resultToken: responseToken,
mfaResultToken: responseToken,
mfaError: null,
};
} catch (error) {
if (error instanceof TotpServiceError || error instanceof RecoveryCodeError)
return failure(409, 'securityError', 'TOTP could not be disabled.');
return failure(
500,
'securityError',
'TOTP could not be disabled. Try again later.',
);
}
};
export const actions = {
beginEnrollment,
confirmEnrollment,
cancelEnrollment,
regenerateRecoveryCodes,
disableTotp,
beginTotpEnrollment: beginEnrollment,
confirmTotpEnrollment: confirmEnrollment,
cancelTotpEnrollment: cancelEnrollment,
};
+67
View File
@@ -0,0 +1,67 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
let { data: rawData, form } = $props();
const data = $derived(rawData as any);
let consumedToken = $state<string | null>(null);
let confirmRegenerate = $state(false);
let confirmDisable = $state(false);
let copied = $state(false);
let displayedRecoveryCodes = $state<string[] | null>(null);
let setupSecret = $state<{ token: string; factorId: string; otpauthUri: string; manualKey: string; expiresAt: unknown } | null>(null);
const result = $derived(form?.mfaResultToken && form.mfaResultToken !== consumedToken ? form : null);
$effect(() => {
if (form?.mfaResultToken && form.mfaResultToken !== consumedToken) {
consumedToken = form.mfaResultToken;
if (typeof form.otpauthUri === 'string' && typeof form.manualKey === 'string') {
setupSecret = {
token: form.mfaResultToken,
factorId: String(form.factorId ?? (form as any).pendingFactorId ?? ''),
otpauthUri: form.otpauthUri,
manualKey: form.manualKey,
expiresAt: form.expiresAt ?? (form as any).expires_at
};
} else {
setupSecret = null;
}
if (Array.isArray(form.recoveryCodes) && form.recoveryCodes.length === 10) displayedRecoveryCodes = [...form.recoveryCodes];
else displayedRecoveryCodes = null;
}
});
const adminRequired = $derived(Boolean(data?.requiredByAdministratorRole));
const enabled = $derived(Boolean(data?.enabled));
const pending = $derived(data?.pendingFactor ?? null);
const active = $derived(data?.activeFactor ?? null);
const pendingId = $derived(pending ? String(pending.factorId ?? pending.id ?? '') : '');
const setup = $derived(setupSecret ?? pending);
const recoveryCodes = $derived(displayedRecoveryCodes);
$effect(() => {
if (setupSecret && pendingId && setupSecret.factorId && pendingId !== setupSecret.factorId) setupSecret = null;
});
async function copyCodes() {
if (!recoveryCodes?.length) return;
try { await navigator.clipboard.writeText(recoveryCodes.join('\n')); copied = true; } catch { copied = false; }
}
function date(value: unknown) { if (!value) return 'NOT RECORDED'; const parsed = new Date(String(value)); return Number.isNaN(parsed.valueOf()) ? String(value) : parsed.toLocaleString(); }
</script>
<svelte:head><title>Security — SHD.build</title></svelte:head>
<main class="security-page"><section class="security-console">
<div class="console-head"><span>ACCOUNT SECURITY</span><b>SHD // IDENTITY</b></div>
<header class="security-header"><div><p class="eyebrow"><span></span> AUTHENTICATION POSTURE</p><h1>SECOND<br/><em>FACTOR.</em></h1></div><strong class:required={adminRequired}>{adminRequired ? (enabled ? 'REQUIRED · ACTIVE' : 'REQUIRED · SETUP NEEDED') : (enabled ? 'ACTIVE' : 'OPTIONAL')}</strong></header>
<p class="security-intro">Time-based codes add a separate check to password, email, Discord, and passkey sign-in. SHD.build stores the factor, not a QR image or external QR-service request.</p>
{#if form?.mfaError}<p class="error" role="alert" aria-live="polite">{form.mfaError}</p>{/if}
{#if result && !recoveryCodes}<p class="success" role="status" aria-live="polite">Security settings updated.</p>{/if}
{#if recoveryCodes}
<section class="recovery-panel" aria-labelledby="recovery-title"><p class="eyebrow">ONE-TIME DISPLAY</p><h2 id="recovery-title">SAVE YOUR RECOVERY CODES</h2><p class="warning"><b>Save these ten codes now.</b> They are shown once and cannot be displayed again after this result is consumed or you leave this page.</p><div class="code-grid" aria-label="Ten recovery codes">{#each recoveryCodes as code}<code>{code}</code>{/each}</div><button type="button" class="secondary" onclick={copyCodes}>{copied ? 'COPIED ALL CODES' : 'COPY ALL CODES'}</button><p class="identity-note">If copying is unavailable, select the codes above and save them in a password manager or offline.</p></section>
{/if}
<section class="factor-card"><div class="card-heading"><div><p class="eyebrow">TOTP AUTHENTICATOR</p><h2>{enabled ? 'AUTHENTICATOR ACTIVE' : 'SET UP AUTHENTICATOR'}</h2></div><span class:active={enabled}>{enabled ? 'ACTIVE' : 'NOT CONFIGURED'}</span></div>
{#if setup}<div class="setup-box"><h3>{enabled ? 'REPLACEMENT SETUP IN PROGRESS' : 'SETUP IN PROGRESS'}</h3><p>Open the link in your authenticator, or enter the key manually. Confirm with the six-digit code from the app before {date(setup.expiresAt ?? setup.expires_at)}.</p>{#if setup.otpauthUri}<a class="secondary action-link" href={setup.otpauthUri}>OPEN IN AUTHENTICATOR ↗</a>{/if}{#if setup.manualKey ?? setup.base32Key}<label class="field">MANUAL BASE32 KEY<input readonly value={setup.manualKey ?? setup.base32Key} aria-label="Manual Base32 authenticator key"/></label>{/if}<PendingForm action="?/confirmEnrollment"><label class="field">SIX-DIGIT CONFIRMATION CODE<input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" minlength="6" maxlength="6" required/></label><input type="hidden" name="factorId" value={setup.factorId ?? setup.id ?? ''}/><button class="primary">CONFIRM AND ACTIVATE →</button></PendingForm><PendingForm action="?/cancelEnrollment" toast={false}><button class="secondary">CANCEL SETUP</button></PendingForm></div>
{:else if enabled}<p class="factor-meta">Enabled {date(active?.enabledAt ?? active?.enabled_at)} · Last assurance {date(data?.lastAssuranceAt ?? data?.last_assurance_at)} · Recovery codes remaining: <b>{data?.recoveryCodeCount ?? data?.recovery_count ?? 0}</b></p><div class="factor-actions"><button type="button" class="secondary" onclick={() => confirmRegenerate = true}>REGENERATE RECOVERY CODES</button><PendingForm action="?/beginEnrollment" toast={false}><button class="secondary">REPLACE AUTHENTICATOR</button></PendingForm>{#if !adminRequired}<button type="button" class="danger" onclick={() => confirmDisable = true}>DISABLE AUTHENTICATOR</button>{/if}</div>{#if confirmRegenerate}<div class="confirm-box"><b>REGENERATE RECOVERY CODES?</b><span>Existing recovery codes stop working immediately.</span><div><button type="button" class="secondary" onclick={() => confirmRegenerate = false}>CANCEL</button><PendingForm action="?/regenerateRecoveryCodes"><button class="primary">CONFIRM REGENERATE</button></PendingForm></div></div>{/if}{#if confirmDisable}<div class="confirm-box danger-box"><b>DISABLE AUTHENTICATOR?</b><span>This removes the second factor from this account.</span><div><button type="button" class="secondary" onclick={() => confirmDisable = false}>CANCEL</button><PendingForm action="?/disableTotp"><button class="danger">CONFIRM DISABLE</button></PendingForm></div></div>{/if}
{:else}<p class="factor-meta">No authenticator is active. Setup takes one authenticator app and a few minutes.</p><PendingForm action="?/beginEnrollment" toast={false}><button class="primary">BEGIN AUTHENTICATOR SETUP →</button></PendingForm>{/if}
</section>
<a class="back-link" href="/account">← RETURN TO ACCOUNT</a>
</section></main>
<style>
.security-page{max-width:920px;margin:0 auto;padding:28px 14px}.security-console{display:grid;gap:18px}.console-head{display:flex;justify-content:space-between;border-bottom:1px solid var(--shd-border);padding:12px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.console-head b{color:var(--shd-text-dim);font-weight:700}.security-header{display:flex;align-items:end;justify-content:space-between;gap:18px;padding:12px 0}.security-header h1{margin:0;font-size:clamp(2.8rem,8vw,6rem);line-height:.84}.security-header em{color:var(--shd-orange);font-style:normal}.security-header strong{border:1px solid var(--shd-border);padding:10px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.security-header strong.required{color:var(--shd-orange);border-color:var(--shd-orange)}.security-intro,.factor-meta{color:var(--shd-text-muted);max-width:70ch}.factor-card,.recovery-panel{border:1px solid var(--shd-border);background:#0d1011;padding:clamp(16px,3vw,28px);display:grid;gap:16px}.card-heading{display:flex;justify-content:space-between;gap:12px}.card-heading h2,.recovery-panel h2{margin:0}.card-heading span{height:max-content;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900}.card-heading span.active{color:var(--shd-orange)}.setup-box{display:grid;gap:13px;border-left:2px solid var(--shd-orange);padding-left:16px}.setup-box h3{margin:0;color:var(--shd-orange)}.action-link{display:inline-flex;width:max-content;text-decoration:none}.factor-actions{display:flex;flex-wrap:wrap;gap:8px}.confirm-box{display:grid;gap:7px;border-top:1px solid var(--shd-border);padding-top:14px}.confirm-box>span{color:var(--shd-text-muted)}.confirm-box>div{display:flex;gap:8px;flex-wrap:wrap}.recovery-panel{border-color:var(--shd-orange)}.warning{border-left:2px solid var(--shd-orange);padding-left:10px;color:var(--shd-text-muted)}.code-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.code-grid code{padding:10px;background:#070909;border:1px solid var(--shd-border);color:var(--shd-text);font-size:1rem;letter-spacing:1px;user-select:all}.identity-note{color:var(--shd-text-dim)}.back-link{color:var(--shd-orange);text-underline-offset:3px}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--shd-orange);outline-offset:3px}@media(max-width:600px){.security-header{align-items:start;flex-direction:column}.code-grid{grid-template-columns:1fr}}
</style>
@@ -0,0 +1,364 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { connect, type Database } from '@tursodatabase/database';
import { createHash } from 'node:crypto';
import { Secret } from 'otpauth';
const {
key,
databaseRef,
userRef,
administratorRef,
auditEvents,
auditFailure,
} = vi.hoisted(() => ({
key: Buffer.alloc(32, 0x71).toString('base64url'),
databaseRef: { current: null as Database | null },
userRef: { current: null as Record<string, unknown> | null },
administratorRef: { current: false },
auditEvents: [] as Array<Record<string, unknown>>,
auditFailure: { current: false },
}));
vi.mock('$env/dynamic/private', () => ({
env: { MFA_TOTP_ENCRYPTION_KEYS: `active:${key}` },
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => databaseRef.current),
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => userRef.current),
}));
vi.mock('$lib/server/auth/bootstrap', () => ({
isFullAdministrator: vi.fn(async () => administratorRef.current),
}));
vi.mock('$lib/server/audit', () => ({
recordIsacAudit: vi.fn(async (event: Record<string, unknown>) => {
if (auditFailure.current) throw new Error('audit unavailable');
auditEvents.push(event);
}),
}));
import { generateTotpCode } from '$lib/server/auth/mfa-totp';
import { actions, load } from './+page.server';
const SESSION_TOKEN = 'current-session-token';
function hashToken(token: string): string {
return createHash('sha256').update(token, 'utf8').digest('hex');
}
function cookies() {
const values = new Map<string, string>([['shd_session', SESSION_TOKEN]]);
return {
values,
get: (name: string) => values.get(name),
set: vi.fn((name: string, value: string) => values.set(name, value)),
delete: vi.fn((name: string) => values.delete(name)),
};
}
function event(
cookieJar: ReturnType<typeof cookies>,
fields: Record<string, string> = {},
) {
return {
cookies: cookieJar,
request: new Request('https://shd.build/account/security', {
method: 'POST',
body: new URLSearchParams(fields),
}),
} as never;
}
async function createDatabase(): Promise<Database> {
const database = await connect(':memory:');
await database.exec(`
CREATE TABLE mfa_totp_factors (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, state TEXT NOT NULL,
encrypted_secret TEXT NOT NULL, encryption_key_id TEXT NOT NULL,
issuer TEXT NOT NULL, account_label TEXT NOT NULL, algorithm TEXT NOT NULL,
digits INTEGER NOT NULL, period_seconds INTEGER NOT NULL,
last_counter INTEGER, failed_attempts INTEGER NOT NULL DEFAULT 0,
locked_until TEXT, expires_at TEXT, created_at TEXT NOT NULL,
activated_at TEXT, revoked_at TEXT
);
CREATE UNIQUE INDEX mfa_active ON mfa_totp_factors(user_id)
WHERE state='active';
CREATE UNIQUE INDEX mfa_pending ON mfa_totp_factors(user_id)
WHERE state='pending';
CREATE TABLE mfa_recovery_codes (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, code_hash TEXT NOT NULL,
created_at TEXT NOT NULL, consumed_at TEXT, revoked_at TEXT
);
CREATE TABLE authentication_transactions (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, consumed_at TEXT
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL, created_at TEXT NOT NULL, aal INTEGER NOT NULL,
aal_verified_at TEXT NOT NULL, aal2_verified_at TEXT
);
CREATE TABLE audit_log (
id TEXT PRIMARY KEY, actor_id TEXT NOT NULL, entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL, action TEXT NOT NULL, before_json TEXT,
after_json TEXT, created_at TEXT NOT NULL
);
`);
return database;
}
async function seedCurrentSession(database: Database) {
const now = new Date();
await (
await database.prepare(
`INSERT INTO sessions(
id,user_id,token_hash,expires_at,created_at,aal,aal_verified_at,aal2_verified_at
) VALUES(?,?,?,?,?,?,?,?)`,
)
).run(
'session-1',
'user-1',
hashToken(SESSION_TOKEN),
new Date(now.getTime() + 86_400_000).toISOString(),
now.toISOString(),
1,
now.toISOString(),
null,
);
}
function setUser(overrides: Record<string, unknown> = {}) {
const now = new Date().toISOString();
userRef.current = {
id: 'user-1',
username: 'agent',
loginUsername: 'agent',
role: 'user',
aal: 1,
authLevel: 1,
sessionCreatedAt: now,
aalVerifiedAt: now,
aal2VerifiedAt: null,
...overrides,
};
}
describe('account TOTP actions', () => {
let database: Database;
let cookieJar: ReturnType<typeof cookies>;
beforeEach(async () => {
database = await createDatabase();
databaseRef.current = database;
administratorRef.current = false;
auditFailure.current = false;
auditEvents.length = 0;
cookieJar = cookies();
setUser();
await seedCurrentSession(database);
});
afterEach(async () => {
databaseRef.current = null;
userRef.current = null;
await database.close();
});
it('loads safe state and returns recovery codes only in the confirmation result', async () => {
const started = await actions.beginEnrollment(event(cookieJar));
expect(started).toMatchObject({ enrollmentStarted: true });
const enrollment = started as { factorId: string; secretBase32: string };
const code = generateTotpCode(
Secret.fromBase32(enrollment.secretBase32).bytes,
);
const confirmed = await actions.confirmEnrollment(
event(cookieJar, { factorId: enrollment.factorId, code }),
);
expect(confirmed).toMatchObject({
enrollmentConfirmed: true,
recoveryCodesIssued: 10,
mfaResultToken: expect.any(String),
});
const recoveryCodes = (confirmed as { recoveryCodes: string[] })
.recoveryCodes;
expect(recoveryCodes).toHaveLength(10);
const loaded = await load({ cookies: cookieJar } as never);
const serialized = JSON.stringify(loaded);
expect(serialized).not.toContain(enrollment.secretBase32);
expect(recoveryCodes.every((value) => !serialized.includes(value))).toBe(
true,
);
expect(loaded).toMatchObject({ enabled: true, recoveryCodeCount: 10 });
expect(
auditEvents.every((audit) => {
const text = JSON.stringify(audit);
return (
!text.includes(enrollment.secretBase32) &&
!recoveryCodes.some((code) => text.includes(code))
);
}),
).toBe(true);
});
it('rolls back confirmation when the audit append fails', async () => {
const started = (await actions.beginEnrollment(event(cookieJar))) as {
factorId: string;
secretBase32: string;
};
const code = generateTotpCode(
Secret.fromBase32(started.secretBase32).bytes,
);
auditFailure.current = true;
const result = await actions.confirmEnrollment(
event(cookieJar, { factorId: started.factorId, code }),
);
expect(result).toMatchObject({ status: 500 });
const factor = await (
await database.prepare(
'SELECT state,last_counter FROM mfa_totp_factors WHERE id=?',
)
).get(started.factorId);
expect(factor).toMatchObject({ state: 'pending', last_counter: null });
const recovery = await (
await database.prepare(
'SELECT COUNT(*) AS count FROM mfa_recovery_codes WHERE user_id=?',
)
).get('user-1');
expect(Number(recovery?.count)).toBe(0);
const sessions = await (
await database.prepare(
'SELECT COUNT(*) AS count FROM sessions WHERE user_id=?',
)
).get('user-1');
expect(Number(sessions?.count)).toBe(1);
});
it('rotates the factor, reissues AAL2, and revokes other sessions', async () => {
const started = (await actions.beginEnrollment(event(cookieJar))) as {
factorId: string;
secretBase32: string;
};
const firstCode = generateTotpCode(
Secret.fromBase32(started.secretBase32).bytes,
);
await actions.confirmEnrollment(
event(cookieJar, { factorId: started.factorId, code: firstCode }),
);
const currentToken = cookieJar.values.get('shd_session')!;
const now = new Date().toISOString();
await (
await database.prepare(
'INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at,aal,aal_verified_at,aal2_verified_at) VALUES(?,?,?,?,?,?,?,?)',
)
).run(
'other-session',
'user-1',
hashToken('other-session-token'),
new Date(Date.now() + 86_400_000).toISOString(),
now,
1,
now,
null,
);
setUser({
aal: 2,
authLevel: 2,
aal2VerifiedAt: now,
aalVerifiedAt: now,
});
const replacement = (await actions.beginEnrollment(event(cookieJar))) as {
factorId: string;
secretBase32: string;
};
const retained = await (
await database.prepare(
"SELECT state FROM mfa_totp_factors WHERE state='active' AND user_id=?",
)
).get('user-1');
expect(retained?.state).toBe('active');
const replacementCode = generateTotpCode(
Secret.fromBase32(replacement.secretBase32).bytes,
);
const confirmed = await actions.confirmEnrollment(
event(cookieJar, {
factorId: replacement.factorId,
code: replacementCode,
}),
);
expect(confirmed).toMatchObject({ enrollmentConfirmed: true });
expect(cookieJar.values.get('shd_session')).not.toBe(currentToken);
const sessions = await (
await database.prepare('SELECT id FROM sessions WHERE user_id=?')
).all('user-1');
expect(sessions).toHaveLength(1);
expect(
auditEvents.some((audit) => audit.action === 'totp.enrollment_confirmed'),
).toBe(true);
});
it('regenerates recovery codes only after recent AAL2', async () => {
const started = (await actions.beginEnrollment(event(cookieJar))) as {
factorId: string;
secretBase32: string;
};
const code = generateTotpCode(
Secret.fromBase32(started.secretBase32).bytes,
);
const confirmed = (await actions.confirmEnrollment(
event(cookieJar, { factorId: started.factorId, code }),
)) as { recoveryCodes: string[] };
const now = new Date().toISOString();
setUser({ aal: 2, authLevel: 2, aal2VerifiedAt: now });
const regenerated = await actions.regenerateRecoveryCodes(event(cookieJar));
expect(regenerated).toMatchObject({
recoveryCodesRegenerated: true,
recoveryCodesIssued: 10,
mfaResultToken: expect.any(String),
});
expect(
(regenerated as { recoveryCodes: string[] }).recoveryCodes,
).not.toEqual(confirmed.recoveryCodes);
const revoked = await (
await database.prepare(
'SELECT COUNT(*) AS count FROM mfa_recovery_codes WHERE revoked_at IS NOT NULL',
)
).get();
expect(Number(revoked?.count)).toBe(10);
});
it('rejects disable for a full administrator without changing the factor', async () => {
const started = (await actions.beginEnrollment(event(cookieJar))) as {
factorId: string;
secretBase32: string;
};
const code = generateTotpCode(
Secret.fromBase32(started.secretBase32).bytes,
);
await actions.confirmEnrollment(
event(cookieJar, { factorId: started.factorId, code }),
);
const now = new Date().toISOString();
setUser({ aal: 2, authLevel: 2, aal2VerifiedAt: now });
administratorRef.current = true;
const result = await actions.disableTotp(
event(cookieJar, { factorId: started.factorId }),
);
expect(result).toMatchObject({ status: 403 });
const factor = await (
await database.prepare('SELECT state FROM mfa_totp_factors WHERE id=?')
).get(started.factorId);
expect(factor?.state).toBe('active');
});
it('redirects stale unauthenticated actions to sign-in', async () => {
userRef.current = null;
await expect(
actions.beginEnrollment(event(cookieJar)),
).rejects.toMatchObject({
status: 303,
location: '/sign-in',
});
});
});
+9 -11
View File
@@ -1,11 +1,7 @@
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import {
createSession,
getSessionUser,
recordLogin,
} from '$lib/server/auth/session';
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
import { getSessionUser, recordLogin } from '$lib/server/auth/session';
import { completePrimaryAuthentication } from '$lib/server/auth/primary-authentication';
import { getDatabase } from '$lib/server/database';
import { redirect } from '@sveltejs/kit';
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
@@ -190,7 +186,6 @@ export const GET: RequestHandler = async ({
created = true;
}
await ensureBootstrapAdmin(database, String(user.id));
const status = await (
await database.prepare(
'SELECT blocked_at,banned_until FROM user_status WHERE user_id=?',
@@ -209,12 +204,15 @@ export const GET: RequestHandler = async ({
redirect(303, '/sign-in?oauth_error=account_blocked');
}
await saveDiscordProfile(String(user.id), discordUser);
const sessionId = await createSession(cookies, String(user.id));
await recordLogin(String(user.id), 'discord', true, {
const authentication = await completePrimaryAuthentication({
database,
cookies,
userId: String(user.id),
provider: 'discord',
request,
getClientAddress,
credentialId: discordUser.id,
sessionId,
returnPath: created ? '/account?choose_display_name=1' : '/account',
});
redirect(303, created ? '/account?choose_display_name=1' : '/account');
redirect(303, authentication.redirectTo);
};
+12 -10
View File
@@ -1,6 +1,6 @@
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
import { consumeMagicLinkToken } from '$lib/server/auth/magic-link';
import { createSession, recordLogin } from '$lib/server/auth/session';
import { recordLogin } from '$lib/server/auth/session';
import { completePrimaryAuthentication } from '$lib/server/auth/primary-authentication';
import { getDatabase } from '$lib/server/database';
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
@@ -43,15 +43,17 @@ export const GET: RequestHandler = async ({
ON CONFLICT(user_id) DO UPDATE SET email=excluded.email,verified_at=excluded.verified_at,updated_at=excluded.updated_at`)
).run(link.userId, link.email, now, now);
}
await ensureBootstrapAdmin(database, link.userId);
const sessionId = await createSession(cookies, link.userId);
await recordLogin(link.userId, 'magic_link', true, {
const authentication = await completePrimaryAuthentication({
database,
cookies,
userId: link.userId,
provider: 'magic_link',
request,
getClientAddress,
sessionId,
returnPath:
link.purpose === 'verify_email'
? '/account?email_verified=1'
: '/account',
});
redirect(
303,
link.purpose === 'verify_email' ? '/account?email_verified=1' : '/account',
);
redirect(303, authentication.redirectTo);
};
@@ -1,6 +1,6 @@
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
import { consumeChallenge } from '$lib/server/auth/passkeys';
import { createSession, recordLogin } from '$lib/server/auth/session';
import { recordLogin } from '$lib/server/auth/session';
import { completePrimaryAuthentication } from '$lib/server/auth/primary-authentication';
import { getDatabase } from '$lib/server/database';
import {
verifyAuthenticationResponse,
@@ -91,13 +91,14 @@ export const POST: RequestHandler = async ({
'UPDATE passkeys SET counter=?,last_used_at=? WHERE id=?',
)
).run(verification.authenticationInfo.newCounter, now, passkey.id);
await ensureBootstrapAdmin(database, String(passkey.user_id));
const sessionId = await createSession(cookies, String(passkey.user_id));
await recordLogin(String(passkey.user_id), 'passkey', true, {
const authentication = await completePrimaryAuthentication({
database,
cookies,
userId: String(passkey.user_id),
provider: 'passkey',
request,
getClientAddress,
credentialId: String(passkey.id),
sessionId,
});
return json({ verified: true, redirectTo: '/account' });
return json({ verified: true, redirectTo: authentication.redirectTo });
};
+239
View File
@@ -0,0 +1,239 @@
import {
claimAuthenticationTransactionInTransaction,
clearAuthenticationTransactionCookie,
consumeAuthenticationTransaction,
consumeAuthenticationTransactionInTransaction,
getAuthenticationTransaction,
recordAuthenticationAttempt,
} from '$lib/server/auth/authentication-transactions';
import { consumeRecoveryCodeInTransaction } from '$lib/server/auth/mfa-recovery';
import { verifyTotpFactorInTransaction } from '$lib/server/auth/mfa-totp';
import {
createSessionInTransaction,
recordLoginInTransaction,
setSessionCookie,
upgradeSessionToAal2InTransaction,
} from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
function noStore(setHeaders: (headers: Record<string, string>) => void): void {
setHeaders({
'cache-control': 'no-store',
'referrer-policy': 'no-referrer',
});
}
async function currentTransaction(
cookies: Parameters<typeof getAuthenticationTransaction>[0],
) {
return getAuthenticationTransaction(cookies);
}
async function failedFactor(
cookies: Parameters<typeof getAuthenticationTransaction>[0],
setHeaders: (headers: Record<string, string>) => void,
message = 'The verification code is invalid or expired.',
) {
noStore(setHeaders);
const database = await getDatabase();
const attempt = await recordAuthenticationAttempt(cookies, database);
return fail(400, {
twoFactorError: attempt.locked
? 'This verification attempt has expired. Start sign-in again.'
: message,
mfaError: attempt.locked
? 'This verification attempt has expired. Start sign-in again.'
: message,
mfaMethod: 'totp',
attemptsRemaining: Math.max(0, 5 - attempt.attemptCount),
mfaResultToken: null,
});
}
function expiredFailure(method: 'totp' | 'recovery' = 'totp') {
return fail(400, {
twoFactorError:
'This verification attempt has expired. Start sign-in again.',
mfaError: 'This verification attempt has expired. Start sign-in again.',
mfaMethod: method,
attemptsRemaining: 0,
mfaResultToken: null,
});
}
async function completeSecondFactor(input: {
cookies: Parameters<typeof getAuthenticationTransaction>[0];
request: Request;
getClientAddress?: () => string;
setHeaders: (headers: Record<string, string>) => void;
source: 'totp' | 'recovery_code';
code: string;
}) {
noStore(input.setHeaders);
const database = await getDatabase();
class FactorVerificationError extends Error {}
const operation = database.transactionAsync(async (transaction) => {
const claimed = await claimAuthenticationTransactionInTransaction(
transaction,
input.cookies,
);
if (!claimed) return { kind: 'expired' as const };
let verified = false;
if (input.source === 'totp') {
const factor = await (
await transaction.prepare(
"SELECT id FROM mfa_totp_factors WHERE user_id=? AND state='active' LIMIT 1",
)
).get(claimed.userId);
if (!factor) {
return { kind: 'factor-failed' as const };
}
let result;
try {
result = await verifyTotpFactorInTransaction(transaction, {
userId: claimed.userId,
factorId: String(factor.id),
token: input.code,
});
} catch {
throw new FactorVerificationError();
}
verified = result.status === 'verified';
} else {
verified = await consumeRecoveryCodeInTransaction(transaction, {
userId: claimed.userId,
code: input.code,
});
}
if (!verified) {
return { kind: 'factor-failed' as const };
}
const consumed = await consumeAuthenticationTransactionInTransaction(
transaction,
input.cookies,
claimed.id,
);
if (!consumed) {
throw new Error('Unable to consume authentication transaction.');
}
const verifiedAt = new Date();
const upgradedSessionId = await upgradeSessionToAal2InTransaction(
transaction,
input.cookies,
claimed.userId,
verifiedAt,
);
const session = upgradedSessionId
? { sessionId: upgradedSessionId, token: null }
: await createSessionInTransaction(transaction, claimed.userId, {
aal: 2,
aal2VerifiedAt: verifiedAt,
});
if (claimed.kind === 'login' && claimed.provider) {
await recordLoginInTransaction(
transaction,
claimed.userId,
claimed.provider,
true,
{
request: input.request,
getClientAddress: input.getClientAddress,
sessionId: session.sessionId,
aal: 2,
aal2VerifiedAt: verifiedAt.toISOString(),
riskFlags: [`mfa_${input.source}_verified`],
},
);
}
return {
kind: 'complete' as const,
returnPath: claimed.returnPath,
session,
};
});
let result;
try {
result = await operation.immediate();
} catch (error) {
if (error instanceof FactorVerificationError)
return failedFactor(input.cookies, input.setHeaders);
throw error;
}
if (result.kind === 'expired') {
clearAuthenticationTransactionCookie(input.cookies);
return expiredFailure(
input.source === 'recovery_code' ? 'recovery' : 'totp',
);
}
if (result.kind === 'factor-failed')
return failedFactor(input.cookies, input.setHeaders);
if (result.session.token)
setSessionCookie(input.cookies, result.session.token);
clearAuthenticationTransactionCookie(input.cookies);
redirect(303, result.returnPath);
}
export const load: PageServerLoad = async ({ cookies, setHeaders }) => {
noStore(setHeaders);
const transaction = await currentTransaction(cookies);
if (!transaction) redirect(303, '/sign-in?mfa_error=expired');
return {
transaction: {
id: transaction.id,
kind: transaction.kind,
method: 'totp',
label: 'SECURE SESSION',
attemptCount: transaction.attemptCount,
expiresAt: transaction.expiresAt,
},
};
};
export const actions = {
verifyTotp: async ({ cookies, request, getClientAddress, setHeaders }) => {
noStore(setHeaders);
const form = await request.formData();
const code = String(form.get('code') ?? '').trim();
if (!/^\d{6}$/.test(code)) return failedFactor(cookies, setHeaders);
return completeSecondFactor({
cookies,
request,
getClientAddress,
setHeaders,
source: 'totp',
code,
});
},
useRecoveryCode: async ({
cookies,
request,
getClientAddress,
setHeaders,
}) => {
noStore(setHeaders);
const form = await request.formData();
const code = String(form.get('recoveryCode') ?? '').trim();
return completeSecondFactor({
cookies,
request,
getClientAddress,
setHeaders,
source: 'recovery_code',
code,
});
},
cancel: async ({ cookies, setHeaders }) => {
noStore(setHeaders);
await consumeAuthenticationTransaction(cookies);
redirect(303, '/sign-in');
},
} satisfies Actions;
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
let { data: rawData, form } = $props();
const data = $derived(rawData as any);
let recoveryMode = $state(false);
let consumedToken = $state<string | null>(null);
const transaction = $derived(data?.transaction ?? {});
const response = $derived(form?.mfaResultToken && form.mfaResultToken !== consumedToken ? form : null);
$effect(() => {
if (form?.mfaResultToken && form.mfaResultToken !== consumedToken) consumedToken = form.mfaResultToken;
});
const error = $derived(form?.mfaError ?? '');
const method = $derived(form?.mfaMethod ?? transaction.method ?? 'totp');
</script>
<svelte:head><title>Verification — SHD.build</title></svelte:head>
<main class="auth-page"><div class="auth-frame"><section class="auth-intel">
<p class="eyebrow"><span></span> SHD COMMUNITY IDENTITY</p><h1>SECOND<br/><em>CHECK.</em></h1>
<p>Confirm this sign-in with the authenticator already assigned to your Agent.</p>
<div class="auth-status"><span><i></i> VERIFICATION REQUIRED</span><span>{transaction.label ?? 'SECURE SESSION'}</span></div>
</section><section class="auth-console">
<div class="console-head"><span>MULTI-FACTOR AUTH</span><b>SHD // AUTH</b></div>
<p class="auth-mode-note">Enter a six-digit authenticator code. Recovery codes are single-use alternatives.</p>
{#if error}<p class="error" id="mfa-error" role="alert">{error}{#if form?.attemptsRemaining !== undefined} <span>{form.attemptsRemaining} attempts remaining.</span>{/if}</p>{/if}
{#if response}<p class="success" aria-live="polite">Verification accepted. Continuing your secure session…</p>{/if}
{#if !recoveryMode}
<PendingForm action="?/verifyTotp" toast={false}>
<label class="field" for="totp-code">AUTHENTICATOR CODE<input id="totp-code" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" minlength="6" maxlength="6" required aria-describedby={error ? 'mfa-error' : undefined}/></label>
<button class="primary full">VERIFY CODE →</button>
</PendingForm>
<button type="button" class="text-action" onclick={() => recoveryMode = true}>USE A RECOVERY CODE INSTEAD</button>
{:else}
<PendingForm action="?/useRecoveryCode" toast={false}>
<label class="field" for="recovery-code">RECOVERY CODE<input id="recovery-code" name="recoveryCode" autocomplete="off" spellcheck="false" required aria-describedby={error ? 'mfa-error' : undefined}/></label>
<button class="primary full">USE RECOVERY CODE →</button>
</PendingForm>
<button type="button" class="text-action" onclick={() => recoveryMode = false}>USE AUTHENTICATOR CODE</button>
{/if}
<PendingForm action="?/cancel" toast={false}><button class="secondary full">CANCEL SIGN-IN</button></PendingForm>
<small class="identity-note">Transaction {transaction.id ?? 'pending'}{method ? ` · ${method.toUpperCase()}` : ''}</small>
</section></div></main>
<style>
.auth-page{min-height:70vh;display:grid;place-items:center;padding:28px 14px}.auth-frame{width:min(940px,100%);display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--shd-border);background:rgba(10,13,14,.94);box-shadow:0 20px 70px #0008}.auth-intel,.auth-console{padding:clamp(22px,5vw,48px)}.auth-intel{display:grid;align-content:center;gap:18px;background:linear-gradient(145deg,#171d1d,#0b0e0f)}.auth-intel h1{margin:0;font-size:clamp(2.5rem,6vw,5rem);line-height:.9}.auth-intel h1 em{color:var(--shd-orange);font-style:normal}.auth-intel p:not(.eyebrow){max-width:34ch;color:var(--shd-text-muted)}.auth-console{display:grid;gap:14px}.console-head{display:flex;justify-content:space-between;border-bottom:1px solid var(--shd-border);padding-bottom:12px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.console-head b{color:var(--shd-text-dim);font-weight:700}.auth-mode-note{color:var(--shd-text-muted);margin:0}.auth-status{display:flex;flex-wrap:wrap;gap:10px;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.7px}.auth-status i{display:inline-block;width:7px;height:7px;background:var(--shd-orange);border-radius:50%;margin-right:5px}.text-action{border:0;background:transparent;color:var(--shd-orange);text-align:left;text-decoration:underline;text-underline-offset:3px;padding:3px 0}.identity-note{color:var(--shd-text-dim)}button:focus-visible,input:focus-visible{outline:2px solid var(--shd-orange);outline-offset:3px}@media(max-width:700px){.auth-frame{grid-template-columns:1fr}.auth-intel{padding-bottom:28px}.auth-console{padding-top:28px}}
</style>
+280
View File
@@ -0,0 +1,280 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { connect, type Database } from '@tursodatabase/database';
import {
createAuthenticationTransaction,
type AuthenticationTransaction,
} from '$lib/server/auth/authentication-transactions';
import { issueRecoveryCodes } from '$lib/server/auth/mfa-recovery';
const mocks = vi.hoisted(() => ({ database: null as Database | null }));
const providers = ['password', 'discord', 'magic_link', 'passkey'] as const;
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
import { actions } from './+page.server';
function cookieJar(seed?: Map<string, string>) {
const values = new Map(seed);
return {
values,
get: (name: string) => values.get(name),
set: vi.fn((name: string, value: string) => values.set(name, value)),
delete: vi.fn((name: string) => values.delete(name)),
};
}
function formRequest(name: string, value: string): Request {
const form = new FormData();
form.set(name, value);
return new Request('http://localhost/auth/two-factor', {
method: 'POST',
body: form,
});
}
async function createDatabase(failSessionCreation = false): Promise<Database> {
const database = await connect(':memory:');
await database.exec(`
CREATE TABLE authentication_transactions (
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL, user_id TEXT NOT NULL,
provider TEXT, kind TEXT NOT NULL, return_path TEXT NOT NULL, required_aal INTEGER NOT NULL,
attempt_count INTEGER NOT NULL, expires_at TEXT NOT NULL,
created_at TEXT NOT NULL, consumed_at TEXT
);
CREATE TABLE mfa_recovery_codes (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, code_hash TEXT NOT NULL,
created_at TEXT NOT NULL, consumed_at TEXT, revoked_at TEXT
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL, created_at TEXT NOT NULL, aal INTEGER NOT NULL,
aal_verified_at TEXT NOT NULL, aal2_verified_at TEXT
);
CREATE TABLE login_events (
id TEXT PRIMARY KEY, user_id TEXT, provider TEXT NOT NULL,
succeeded INTEGER NOT NULL, ip_address TEXT, user_agent TEXT,
client_name TEXT, operating_system TEXT, device_type TEXT,
location_country TEXT, location_region TEXT, location_city TEXT,
failure_reason TEXT, risk_flags TEXT, credential_id TEXT, session_id TEXT,
aal INTEGER, aal2_verified_at TEXT, created_at TEXT NOT NULL
);
`);
if (failSessionCreation) {
await database.exec(`
CREATE TRIGGER fail_aal2_session
BEFORE INSERT ON sessions
WHEN NEW.aal=2
BEGIN
SELECT RAISE(ABORT, 'session insert failed');
END;
`);
}
return database;
}
async function seedAuthentication(
database: Database,
cookies: ReturnType<typeof cookieJar>,
provider: 'password' | 'discord' | 'magic_link' | 'passkey' = 'password',
): Promise<{ transaction: AuthenticationTransaction; code: string }> {
const transaction = await createAuthenticationTransaction(
cookies as never,
database,
{
userId: 'user-1',
kind: 'login',
provider,
returnPath: '/account',
},
);
const issued = await issueRecoveryCodes(database, { userId: 'user-1' });
return { transaction, code: issued.codes[0] };
}
async function counts(database: Database) {
return {
transaction: await (
await database.prepare(
'SELECT consumed_at FROM authentication_transactions LIMIT 1',
)
).get(),
recovery: await (
await database.prepare(
'SELECT COUNT(*) AS count FROM mfa_recovery_codes WHERE consumed_at IS NOT NULL',
)
).get(),
sessions: await (
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
).get(),
logins: await (
await database.prepare('SELECT COUNT(*) AS count FROM login_events')
).get(),
loginEvent: await (
await database.prepare(
'SELECT provider,succeeded FROM login_events ORDER BY created_at DESC LIMIT 1',
)
).get(),
};
}
describe('two-factor server transition', () => {
let database: Database;
beforeEach(async () => {
database = await createDatabase();
mocks.database = database;
});
afterEach(async () => {
mocks.database = null;
await database.close();
});
it.each(providers)(
'records the completed MFA login with the persisted %s provider',
async (provider) => {
const cookies = cookieJar();
const { code } = await seedAuthentication(database, cookies, provider);
await expect(
actions.useRecoveryCode?.({
cookies,
request: formRequest('recoveryCode', code),
getClientAddress: () => '127.0.0.1',
setHeaders: vi.fn(),
} as never),
).rejects.toMatchObject({ status: 303, location: '/account' });
const state = await counts(database);
expect(state.transaction.consumed_at).toBeTruthy();
expect(Number(state.recovery.count)).toBe(1);
expect(Number(state.sessions.count)).toBe(1);
expect(Number(state.logins.count)).toBe(1);
expect(state.loginEvent).toMatchObject({ provider, succeeded: 1 });
expect(cookies.set).toHaveBeenCalledWith(
'shd_session',
expect.any(String),
expect.objectContaining({ path: '/', httpOnly: true }),
);
},
);
it('does not record a successful login for an abandoned MFA challenge', async () => {
const cookies = cookieJar();
await seedAuthentication(database, cookies, 'discord');
const state = await counts(database);
expect(Number(state.logins.count)).toBe(0);
expect(state.loginEvent).toBeUndefined();
});
it('never returns an invalid recovery code in failure data', async () => {
const cookies = cookieJar();
await seedAuthentication(database, cookies);
const result = await actions.useRecoveryCode?.({
cookies,
request: formRequest('recoveryCode', 'NOT-A-REAL-CODE'),
setHeaders: vi.fn(),
} as never);
expect(result).toMatchObject({ status: 400 });
expect(JSON.stringify(result)).not.toContain('NOT-A-REAL-CODE');
expect(Number((await counts(database)).logins.count)).toBe(0);
});
it('rejects a duplicate submission without consuming another factor', async () => {
const firstCookies = cookieJar();
const { code } = await seedAuthentication(database, firstCookies);
const duplicateCookies = cookieJar(new Map(firstCookies.values));
await expect(
actions.useRecoveryCode?.({
cookies: firstCookies,
request: formRequest('recoveryCode', code),
setHeaders: vi.fn(),
} as never),
).rejects.toMatchObject({ status: 303 });
const beforeDuplicate = await counts(database);
const result = await actions.useRecoveryCode?.({
cookies: duplicateCookies,
request: formRequest('recoveryCode', code),
setHeaders: vi.fn(),
} as never);
expect(result).toMatchObject({
status: 400,
data: { attemptsRemaining: 0 },
});
const afterDuplicate = await counts(database);
expect(Number(afterDuplicate.recovery.count)).toBe(
Number(beforeDuplicate.recovery.count),
);
expect(Number(afterDuplicate.sessions.count)).toBe(1);
expect(Number(afterDuplicate.logins.count)).toBe(1);
});
it('serializes cancellation with completion without corrupting factor state', async () => {
const seedCookies = cookieJar();
const { code } = await seedAuthentication(database, seedCookies);
const submitCookies = cookieJar(new Map(seedCookies.values));
const cancelCookies = cookieJar(new Map(seedCookies.values));
const outcomes = await Promise.allSettled([
actions.useRecoveryCode?.({
cookies: submitCookies,
request: formRequest('recoveryCode', code),
setHeaders: vi.fn(),
} as never),
actions.cancel?.({
cookies: cancelCookies,
setHeaders: vi.fn(),
} as never),
]);
expect(outcomes).toHaveLength(2);
for (const outcome of outcomes) {
if (outcome.status === 'rejected')
expect(outcome.reason).toMatchObject({ status: 303 });
}
const state = await counts(database);
const recoveryConsumed = Number(state.recovery.count);
const sessions = Number(state.sessions.count);
const logins = Number(state.logins.count);
expect(state.transaction.consumed_at).toBeTruthy();
expect(recoveryConsumed).toBeLessThanOrEqual(1);
expect(sessions).toBeLessThanOrEqual(1);
expect(logins).toBe(sessions);
if (sessions === 0) expect(recoveryConsumed).toBe(0);
else expect(recoveryConsumed).toBe(1);
});
it('rolls back the factor, transaction, session, and login event when session creation fails', async () => {
await database.close();
database = await createDatabase(true);
mocks.database = database;
const cookies = cookieJar();
const { code } = await seedAuthentication(database, cookies);
await expect(
actions.useRecoveryCode?.({
cookies,
request: formRequest('recoveryCode', code),
setHeaders: vi.fn(),
} as never),
).rejects.toThrow('session insert failed');
const state = await counts(database);
expect(state.transaction.consumed_at).toBeNull();
expect(Number(state.recovery.count)).toBe(0);
expect(Number(state.sessions.count)).toBe(0);
expect(Number(state.logins.count)).toBe(0);
expect(cookies.set).not.toHaveBeenCalledWith(
'shd_session',
expect.anything(),
expect.anything(),
);
});
});
+127 -4
View File
@@ -36,6 +36,7 @@ async function validateSkillChipSelections(
if (!loadout.chips.some((chip) => chip.setId)) return null;
const [
sets,
abilityLinks,
focusAbilities,
primaryOptions,
primaryValues,
@@ -44,12 +45,28 @@ async function validateSkillChipSelections(
] = await Promise.all([
(
await database.prepare(
'SELECT id,specialization_ability_id FROM skill_chip_sets WHERE active=1',
`SELECT chip.id,chip.specialization_ability_id
FROM skill_chip_sets chip
JOIN specialization_abilities ability
ON ability.id=chip.specialization_ability_id AND ability.active=1
WHERE chip.active=1`,
)
).all(),
(
await database.prepare(
'SELECT ability_id,ability_slot FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot',
`SELECT link.ability_id,link.compatible_ability_id
FROM specialization_ability_skill_chip_links link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
)
).all(),
(
await database.prepare(
`SELECT link.ability_id,link.ability_slot
FROM specialization_focus_abilities link
JOIN specialization_focuses focus ON focus.id=link.focus_id AND focus.active=1
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
WHERE link.focus_id=? ORDER BY link.ability_slot`,
)
).all(loadout.specialization.focusId),
(
@@ -94,11 +111,20 @@ async function validateSkillChipSelections(
const expectedAbility = focusAbilities.find(
(row) => Number(row.ability_slot) === Math.floor(index / 3) + 1,
);
const ownerAbilityId = String(definition?.specialization_ability_id ?? '');
const expectedAbilityId = String(expectedAbility?.ability_id ?? '');
const sharedWithExpectedAbility = abilityLinks.some((link) => {
const left = String(link.ability_id);
const right = String(link.compatible_ability_id);
return (
(left === ownerAbilityId && right === expectedAbilityId) ||
(left === expectedAbilityId && right === ownerAbilityId)
);
});
if (
!definition ||
!expectedAbility ||
String(definition.specialization_ability_id) !==
String(expectedAbility.ability_id)
(ownerAbilityId !== expectedAbilityId && !sharedWithExpectedAbility)
)
return `Skill Chip ${index + 1} is not compatible with its assigned ability.`;
const primaryRange = rangeFor(
@@ -125,6 +151,74 @@ async function validateSkillChipSelections(
return null;
}
async function validateEquipmentTalentSelections(
database: Awaited<ReturnType<typeof getDatabase>>,
loadout: LoadoutDraft,
) {
const [gearTalents, weaponTalents, weapons, talentTiers] = await Promise.all([
(
await database.prepare('SELECT gear_id,talent_id FROM gear_talents')
).all(),
(
await database.prepare(
'SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options',
)
).all(),
(await database.prepare('SELECT id,exotic_talent_id FROM weapons')).all(),
(await database.prepare('SELECT talent_id,tier FROM talent_tiers')).all(),
]);
const hasTier = (talentId: string, tier: 1 | 2) =>
talentTiers.some(
(entry) =>
String(entry.talent_id) === talentId &&
String(entry.tier) === (tier === 2 ? 'II' : 'I'),
);
for (const slot of Object.keys(loadout.gear)) {
const selection = loadout.gear[slot];
if (!selection.talentId) continue;
if (
!gearTalents.some(
(entry) =>
String(entry.gear_id) === selection.itemId &&
String(entry.talent_id) === selection.talentId,
)
)
return `${slot} has a Talent that is not available for the equipped item.`;
if (!hasTier(selection.talentId, selection.talentTier))
return `${slot} Talent T${selection.talentTier} is not configured.`;
}
for (let index = 0; index < loadout.weapons.length; index += 1) {
const selection = loadout.weapons[index];
const label = index ? 'Secondary weapon' : 'Primary weapon';
for (const configured of [
{ id: selection.talent1Id, tier: selection.talent1Tier, slot: 1 },
{ id: selection.talent2Id, tier: selection.talent2Tier, slot: 2 },
]) {
if (!configured.id) continue;
if (
!weaponTalents.some(
(entry) =>
String(entry.weapon_id) === selection.itemId &&
Number(entry.talent_slot) === configured.slot &&
String(entry.talent_id) === configured.id,
)
)
return `${label} has a Talent that is not available in slot ${configured.slot}.`;
if (!hasTier(configured.id, configured.tier))
return `${label} Talent ${configured.slot} T${configured.tier} is not configured.`;
}
const weapon = weapons.find(
(entry) => String(entry.id) === selection.itemId,
);
const exoticTalentId = String(weapon?.exotic_talent_id ?? '');
if (exoticTalentId && !hasTier(exoticTalentId, selection.exoticTalentTier))
return `${label} exotic Talent T${selection.exoticTalentTier} is not configured.`;
}
return null;
}
export const load: PageServerLoad = async ({ cookies, url }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
@@ -166,6 +260,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talentEffects,
talentTemplates,
chipSets,
abilitySkillChipLinks,
chipTwoPiece,
chipPrimaryOptions,
chipPrimaryQualityValues,
@@ -258,6 +353,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
`SELECT link.ability_id,link.compatible_ability_id
FROM specialization_ability_skill_chip_links link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
)
).all(),
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
@@ -460,6 +563,20 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
);
return {
...set,
category:
specializationAbilities.find(
(ability) => ability.id === set.specialization_ability_id,
)?.name ?? 'SKILL CHIP SET',
compatibleAbilityIds: [
String(set.specialization_ability_id),
...abilitySkillChipLinks.flatMap((link) =>
link.ability_id === set.specialization_ability_id
? [String(link.compatible_ability_id)]
: link.compatible_ability_id === set.specialization_ability_id
? [String(link.ability_id)]
: [],
),
],
iconUrl: iconUrl('skill_chip', set.id),
twoPiece:
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
@@ -592,6 +709,12 @@ export const actions: Actions = {
return fail(400, { buildError: 'Choose a valid build visibility.' });
const database = await getDatabase();
const equipmentTalentError = await validateEquipmentTalentSelections(
database,
loadout,
);
if (equipmentTalentError)
return fail(400, { buildError: equipmentTalentError });
const skillChipError = await validateSkillChipSelections(database, loadout);
if (skillChipError) return fail(400, { buildError: skillChipError });
const now = new Date().toISOString();
+156 -126
View File
@@ -12,11 +12,12 @@
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
import TalentTierChoice from '$lib/components/builder/TalentTierChoice.svelte';
import { summarizeLoadout } from '$lib/builder/loadout-summary';
import { toast } from '$lib/toast';
import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
type LoadoutDraft, type Roll, type SkillChipQuality
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, migrateLoadoutDraftV5, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
type LoadoutDraft, type Roll, type SkillChipQuality, type TalentTier
} from '$lib/builder/loadout';
let { data, form } = $props();
@@ -55,6 +56,9 @@
const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId);
const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId);
const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId);
const chipSupportsAbility = (item: any, abilityId: string) =>
item?.compatibleAbilityIds?.includes(abilityId)
?? item?.specialization_ability_id === abilityId;
const protocolItem = () => data.protocols.find((item) => item.id === draft.protocol.itemId);
const allFocuses = $derived(data.specializations.flatMap((specialization: any) =>
specialization.focuses.map((focus: any) => ({ ...focus, specialization }))
@@ -71,6 +75,8 @@
return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : [];
}) ?? []);
const talent = (id: string) => data.talents.find((item) => item.id === id);
const supportedTalentTier = (id: string, tier: TalentTier): TalentTier =>
talent(id)?.tiers.some((entry: any) => entry.tier === (tier === 2 ? 'II' : 'I')) ? tier : 1;
const brand = (id: string) => data.brands.find((item) => item.id === id);
const unitValue = (value: unknown, statId: string) => {
const numeric = Number(value);
@@ -115,10 +121,11 @@
&& value >= Number(range.min)
&& value <= Number(range.max);
};
const chipSelectionConfigured = (chip: LoadoutDraft['chips'][number]) => {
const chipSelectionConfigured = (chip: LoadoutDraft['chips'][number], abilityId: string) => {
const capacity = skillChipSecondaryCapacity[chip.quality];
const secondaryIds = chip.secondary.map((roll) => roll.statId);
return Boolean(data.chipSets.some((item: any) => item.id === chip.setId))
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
return Boolean(definition && chipSupportsAbility(definition, abilityId))
&& chipProfileConfigured
&& chipRollConfigured(chip.primary, 'primary', chip.quality)
&& chip.secondary.length === capacity
@@ -168,14 +175,14 @@
const chip = draft.chips[index];
const ability = next.abilities[Math.floor(index / 3)];
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
if (chip.setId && (!ability || definition?.specialization_ability_id !== ability.id)) {
if (chip.setId && (!ability || !chipSupportsAbility(definition, ability.id))) {
displaced.push(structuredClone(chip));
draft.chips[index] = createDraft().chips[index];
}
}
for (const chip of displaced) {
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
const abilityIndex = next.abilities.findIndex((ability: any) => ability.id === definition?.specialization_ability_id);
const abilityIndex = next.abilities.findIndex((ability: any) => chipSupportsAbility(definition, ability.id));
if (abilityIndex < 0) continue;
const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId);
if (targetIndex === undefined) continue;
@@ -207,6 +214,7 @@
selection.level = 50;
selection.brandId = item?.brandIds[0] ?? '';
selection.talentId = item?.talentIds[0] ?? '';
selection.talentTier = supportedTalentTier(selection.talentId, Number(item?.tier) >= 2 ? 2 : 1);
selection.health = Number(item?.base_health_min ?? 0);
selection.armor = Number(item?.base_armor_min ?? 0);
const core = coreAllocationRange(Number(item?.tier ?? 0), selection.level, 'gear');
@@ -255,7 +263,10 @@
const core = coreAllocationRange(selection.tier, selection.level, 'weapon');
for (const key of coreKeys) selection[key] = core.min;
selection.talent1Id = item?.talent1Ids[0] ?? '';
selection.talent1Tier = supportedTalentTier(selection.talent1Id, selection.tier >= 2 ? 2 : 1);
selection.talent2Id = item?.talent2Ids[0] ?? '';
selection.talent2Tier = supportedTalentTier(selection.talent2Id, selection.tier >= 2 ? 2 : 1);
selection.exoticTalentTier = supportedTalentTier(item?.exotic_talent_id ?? '', selection.tier >= 2 ? 2 : 1);
selection.rolls = [blankRoll(), blankRoll()];
};
const configureChip = (index: number, id: string) => {
@@ -345,18 +356,21 @@
const value = String(category ?? '').toLowerCase();
if (value.includes('shotgun')) return '/shotgun.png';
if (value.includes('smg') || value.includes('submachine')) return '/smg.png';
if (value.includes('mmr') || value.includes('marksman')) return '/mmr.png';
if (value.includes('lmg') || value.includes('light machine')) return '/lmg.png';
if (value.includes('ar') || value.includes('assault')) return '/ar.png';
return '/rifle.png';
};
const doctrineCell = $derived<MatrixCell>({
id: 'specialization:focus',
label: 'SPECIALIZATION + FOCUS',
name: selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'SELECT DOCTRINE',
label: 'FOCUS',
name: selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'SELECT FOCUS',
meta: selectedFocus?.primary_attribute ?? '',
summary: selectedFocus
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
: 'ROOT CHOICE · DEFINES ALL THREE ABILITIES',
: 'SETS 3 ABILITIES',
iconUrl: selectedFocus?.specialization.iconUrl,
issue: !selectedFocus ? 'SELECT TO COMPLETE LOADOUT' : selectedFocus.abilities.length !== 3 ? 'ABILITY DATA INCOMPLETE' : undefined
issue: !selectedFocus ? 'Select a focus.' : selectedFocus.abilities.length !== 3 ? 'Focus needs 3 abilities.' : undefined
});
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
const ability = selectedFocus?.abilities[index];
@@ -365,26 +379,26 @@
const chipNames = configuredChips.map((chip) => data.chipSets.find((item) => item.id === chip.setId)?.name).filter(Boolean);
return {
id: `ability:${index}`,
label: `ABILITY 0${index + 1}`,
name: ability?.name ?? 'NOT ASSIGNED',
label: `ABILITY ${index + 1}`,
name: ability?.name ?? 'UNASSIGNED',
meta: ability?.attack?.damage_type ?? '',
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
iconUrl: ability?.iconUrl,
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : abilityChips.some((chip) => !chipSelectionConfigured(chip)) ? 'CONFIG REQUIRED' : undefined
issue: !ability ? 'Ability required.' : configuredChips.length < 3 ? `${3 - configuredChips.length} chip${configuredChips.length === 2 ? '' : 's'} required.` : abilityChips.some((chip) => !chipSelectionConfigured(chip, ability.id)) ? 'Chip setup required.' : undefined
};
}));
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
const item = weaponItem(index);
const talents = [selection.talent1Id, selection.talent2Id].map((id) => talent(id)?.name).filter(Boolean);
const talents = [[selection.talent1Id, selection.talent1Tier], [selection.talent2Id, selection.talent2Tier]].map(([id,tier]) => id ? `${talent(String(id))?.name ?? 'Talent'} T${tier}` : '').filter(Boolean);
const details = [...talents, rollSummary(selection.rolls)].filter(Boolean).join(' · ');
return {
id: `weapon:${index}`,
label: `${index ? 'SECONDARY' : 'PRIMARY'} WEAPON`,
name: item?.name ?? 'EMPTY SLOT',
label: index ? 'SECONDARY' : 'PRIMARY',
name: item?.name ?? 'EMPTY',
meta: item?.category ?? '',
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON',
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'CHOOSE WEAPON',
iconUrl: weaponIcon(item?.category),
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT'
issue: item ? undefined : 'Choose a weapon.'
};
}));
const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
@@ -392,36 +406,39 @@
const item = gearItem(slot);
const details = [
brand(selection.brandId)?.name,
talent(selection.talentId)?.name,
selection.talentId ? `${talent(selection.talentId)?.name ?? 'Talent'} T${selection.talentTier}` : '',
rollSummary(selection.rolls)
].filter(Boolean).join(' · ');
return {
id: `gear:${slot}`,
label: slot.toUpperCase(),
name: item?.name ?? 'EMPTY SLOT',
name: item?.name ?? 'EMPTY',
meta: item ? `T${item.tier}` : '',
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR',
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'CHOOSE GEAR',
iconUrl: gearIcon(slot),
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT'
issue: item ? undefined : 'Choose gear.'
};
}));
const protocolCell = $derived<MatrixCell>({
id: 'protocol:0',
label: 'OS PROTOCOL',
name: protocolItem()?.name ?? 'EMPTY SLOT',
label: 'PROTOCOL',
name: protocolItem()?.name ?? 'EMPTY',
meta: protocolItem()?.quality ?? '',
summary: protocolItem()
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
: 'SELECT ONE PROTOCOL',
: 'CHOOSE PROTOCOL',
iconUrl: protocolItem()?.iconUrl,
issue: protocolItem() ? undefined : 'SELECT TO COMPLETE LOADOUT'
issue: protocolItem() ? undefined : 'Choose a protocol.'
});
const loadoutConfigured = $derived(Boolean(
selectedFocus
&& selectedFocus.abilities.length === 3
&& draft.weapons.every((item) => item.itemId)
&& gearSlots.every((slot) => draft.gear[slot].itemId)
&& draft.chips.every(chipSelectionConfigured)
&& draft.chips.every((chip, index) => chipSelectionConfigured(
chip,
selectedFocus.abilities[Math.floor(index / 3)]?.id ?? ''
))
&& draft.protocol.itemId
));
@@ -429,17 +446,24 @@
localStorage.removeItem('shd-build-loadout-draft-v3');
localStorage.removeItem('shd-build-loadout-draft-v4');
if (data.initialBuild) return;
const stored = localStorage.getItem('shd-build-loadout-draft-v5');
const current = localStorage.getItem('shd-build-loadout-draft-v6');
const legacy = current ? null : localStorage.getItem('shd-build-loadout-draft-v5');
const stored = current ?? legacy;
if (!stored) return;
try {
const parsed = parseLoadoutDraft(JSON.parse(stored));
const raw = JSON.parse(stored);
const parsed = current ? parseLoadoutDraft(raw) : migrateLoadoutDraftV5(raw, data);
if (parsed) {
draft = parsed;
if (legacy) {
localStorage.setItem('shd-build-loadout-draft-v6', JSON.stringify(parsed));
localStorage.removeItem('shd-build-loadout-draft-v5');
}
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true;
} else localStorage.removeItem('shd-build-loadout-draft-v5');
} else localStorage.removeItem(current ? 'shd-build-loadout-draft-v6' : 'shd-build-loadout-draft-v5');
} catch {
localStorage.removeItem('shd-build-loadout-draft-v5');
localStorage.removeItem(current ? 'shd-build-loadout-draft-v6' : 'shd-build-loadout-draft-v5');
}
});
$effect(() => {
@@ -457,7 +481,7 @@
: 'Build published to the SHD archive.';
});
function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v5', JSON.stringify(draft));
localStorage.setItem('shd-build-loadout-draft-v6', JSON.stringify(draft));
saved = true;
toast.success('Draft saved locally', 'This loadout is available on this device.');
}
@@ -467,10 +491,10 @@
<main class="app-page builder-page">
<div class="builder-command">
<div class="page-title">
<div><p class="eyebrow"><span></span> COMMAND GRID / LOADOUT 01</p><input id="builder-build-name" name="builder-build-name" bind:value={draft.buildName} aria-label="Build name" maxlength="100"/></div>
<div><p class="eyebrow"><span></span> BUILDER / LOADOUT 01</p><input id="builder-build-name" name="builder-build-name" bind:value={draft.buildName} aria-label="Build name" maxlength="100"/></div>
<div class="actions">
<span class:visible={saved}>SAVED LOCALLY</span>
<button class="secondary" onclick={saveDraft}>SAVE LOCALLY</button>
<span class:visible={saved}>LOCAL SAVED</span>
<button class="secondary" onclick={saveDraft}>SAVE</button>
{#if data.viewer}
<PendingForm action="?/save" class="builder-save-form">
{#snippet children(pending)}
@@ -486,21 +510,21 @@
</div>
{#if data.viewer}
<div class="builder-persistence">
<SegmentedChoice legend="ACCOUNT VISIBILITY" name="build-visibility" options={[
<SegmentedChoice legend="VISIBILITY" name="build-visibility" options={[
{ value:'private', label:'PRIVATE' },
{ value:'unlisted', label:'UNLISTED' },
{ value:'public', label:'PUBLIC' }
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0] as BuildVisibility} required/>
<p>{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}</p>
<p>{persistedVisibility === 'private' ? 'Private to you.' : persistedVisibility === 'unlisted' ? 'Direct link only.' : 'Public listing and link.'}</p>
{#if persistedSlug && savedBuildVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
</div>
{/if}
<div class="builder-status"><span><i></i> KNOWN STATS ONLINE</span><span>ADVANCED MODEL · COMING SOON</span><span>12 LOADOUT CELLS + 9 CHIPS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
<div class="builder-status"><span><i></i> STATS ONLINE</span></div>
</div>
{#if message}<p class="success" role="status">{message}</p>{/if}
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
{#if data.viewer && persistedSlug}<BuildShareControl title={savedBuildTitle} slugOrUrl={new URL(`/builds/${encodeURIComponent(persistedSlug)}`, data.publicOrigin).toString()} visibility={savedBuildVisibility}/>{/if}
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if}
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>NO BUILDER DATA</strong><span>Add active items in ISAC.</span></div>{/if}
<div class="loadout-workspace">
<LoadoutMatrix
{active}
@@ -515,129 +539,135 @@
<section class="loadout-editor">
{#if activeParts[0] === 'specialization'}
<div class="loadout-editor-head"><div><span>AGENT CLASS</span><h2>SPECIALIZATION FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="SPECIALIZATION + FOCUS"/>
<div class="loadout-editor-head"><div><span>SPECIALIZATION</span><h2>FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="FOCUS"/>
{#if selectedFocus}
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt={selectedFocus.specialization.name}/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description has been entered.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'BUILDER READY' : 'ISAC DATA INCOMPLETE'}</b></div>
<div class="builder-ability-chip-panels">
{#each [0,1,2] as abilityIndex}
{@const ability = selectedFocus.abilities[abilityIndex]}
<section>
<header><ItemIcon src={ability?.iconUrl} alt={ability?.name ?? `Ability ${abilityIndex + 1}`}/><div><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'NOT CONFIGURED'}</strong></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></header>
<p>{ability?.description ?? 'ISAC requires this class ability before compatible Skill Chips can be equipped.'}</p>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? draft.chips[chipIndex].quality : 'SELECT CHIP'}</span></button>{/each}</div>
</section>
{/each}
</div>
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
<div class="builder-focus-tree">
<div class="builder-section-title"><div><span>FOCUS UPGRADE TREE</span><small>PREVIOUS ROWS REQUIRE EVERY AVAILABLE RANK 1 + 2 BEFORE ADVANCING</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'ENTRY ROW' : 'LOCKED BY PREVIOUS ROWS'}</b></header><div>
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
<div class="builder-specialization-layout">
<div class="builder-editor-lane">
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt=""/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'READY' : 'NEEDS DATA'}</b></div>
<div class="builder-focus-abilities" aria-label="Assigned abilities">
{#each [0,1,2] as abilityIndex}
{@const ability = selectedFocus.abilities[abilityIndex]}
<button type="button" onclick={() => active = `ability:${abilityIndex}`}><ItemIcon src={ability?.iconUrl} alt="" className="builder-ability-icon"/><span><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'UNASSIGNED'}</strong></span><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3</b></button>
{/each}
</div></section>
{/each}
</div>
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
</div>
<div class="builder-focus-tree builder-editor-lane">
<div class="builder-section-title"><div><span>UPGRADE TREE</span><small>UNLOCK IN ORDER</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'START' : 'PRIOR ROWS'}</b></header><div>
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
{/each}
</div></section>
{/each}
</div>
</div>
<PlannedCombatModel headingId="builder-combat-model-heading" />
{/if}
{:else if activeParts[0] === 'ability'}
{@const abilityIndex = Number(activeParts[1])}
{@const ability = selectedFocus?.abilities[abilityIndex]}
<div class="loadout-editor-head"><div><span>DOCTRINE DEPENDENCY · ABILITY 0{abilityIndex + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'ABILITY NOT ASSIGNED'}</h2></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></div>
<button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← CHANGE SPECIALIZATION + FOCUS</button>
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'UNASSIGNED'}</h2></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></div>
<button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← BACK TO FOCUS</button>
{#if ability}
<div class="builder-specialization-identity ability-identity"><ItemIcon src={ability.iconUrl} alt={ability.name}/><div><small>{selectedFocus.specialization.name} · {selectedFocus.name}</small><h3>{ability.name}</h3><p>{ability.description || 'No ability description has been entered.'}</p></div><b>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK DATA INCOMPLETE'}</b></div>
<div class="builder-section-title"><div><span>ATTACHED SKILL CHIPS</span><small>CHIPS ARE COMPATIBLE WITH THIS ABILITY AND MOVE WITH ITS FOCUS</small></div><b>03 POSITIONS</b></div>
<div class="builder-specialization-identity ability-identity"><ItemIcon src={ability.iconUrl} alt="" className="ability-icon"/><div><small>{selectedFocus.specialization.name} · {selectedFocus.name}</small><h3>{ability.name}</h3><p>{ability.description || 'No ability description.'}</p></div><b>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK DATA NEEDED'}</b></div>
<div class="builder-section-title"><div><span>SKILL CHIPS</span><small>3 SETS</small></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3</b></div>
<div class="builder-ability-chip-panels single">
<section>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${draft.chips[chipIndex].quality} · ${attribute(draft.chips[chipIndex].primary.statId)?.name ?? 'NO PRIMARY'}` : 'SELECT CHIP'}</span></button>{/each}</div>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><ItemIcon src={item?.iconUrl} alt="" className="builder-chip-icon"/><span><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY'}</strong><em>{item ? `${draft.chips[chipIndex].quality} · ${attribute(draft.chips[chipIndex].primary.statId)?.name ?? 'NO PRIMARY'}` : 'SELECT SET'}</em></span></button>{/each}</div>
</section>
</div>
{:else}
<p class="error">Select a Specialization and Focus with configured abilities before attaching Skill Chips.</p>
<p class="error">Choose a focus with 3 abilities first.</p>
{/if}
{:else if activeParts[0] === 'gear'}
{@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)}
<div class="loadout-editor-head"><div><span>GEAR SLOT</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `TIER ${item.tier} · BUILD LEVEL ${selection.level}` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/>
<div class="loadout-editor-head"><div><span>GEAR</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `T${item.tier} · LV ${selection.level}` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="GEAR ITEM"/>
{#if item}
{@const healthRange = gearBaseRange(item, 'health', selection.level)}
{@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null}
<div class="builder-equipment-controls">
<RangeValueControl label="GEAR LEVEL" min={Number(item.tier) === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/>
{#if item.talentIds.length}<BuilderRadioGrid label="EQUIPPED TALENT" name={`gear-${slot}-talent`} items={item.talentIds.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talentId} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if}
</div>
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional while additional game samples are collected.' : 'Base Health and Armor are calculated from the stored gear base ranges at the selected item level. Display values are truncated to match the game.'}</p>
<div class="builder-range-grid"><RangeValueControl label="BASE HEALTH" min={healthRange.min} max={healthRange.max} provisional={healthRange.provisional} bind:value={selection.health}/>{#if armorRange}<RangeValueControl label="BASE ARMOR" min={armorRange.min} max={armorRange.max} provisional={armorRange.provisional} bind:value={selection.armor}/>{/if}</div>
{@const gearCore = coreAllocationRange(Number(item.tier), selection.level, 'gear')}
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{item.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < gearCore.poolMin || coreTotal(selection) > gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={gearCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO GEAR RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, index}{@const rollRange = attributeRange(attribute(roll.statId), 'gear', selection.level)}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== index).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${index + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
{@const augments = data.gearAugments.filter((entry) => entry.gear_slot === slot)}
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>ENABLE COLLECTED UPGRADES · BASE BONUSES APPLY TO THIS ITEM ONLY</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{augment.effect_type === 'Custom' ? `${attribute(augment.stat_attribute_id)?.name} · ${unitValue(augment.value,augment.stat_attribute_id)}` : `+${augment.value}% BASE HEALTH${item.base_armor_min !== null ? ' + ARMOR' : ''}`}</small></span><em>{augment.costs.find((cost: any) => Number(cost.gear_tier) === Number(item.tier))?.unlock_cost ?? '—'} COST · T{item.tier}</em></label>{/each}</div>{/if}
<div class="builder-editor-columns">
<section class="builder-editor-lane" aria-label="Gear identity and base values">
<div class="builder-equipment-controls">
<RangeValueControl label="GEAR LEVEL" min={Number(item.tier) === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/>
{#if item.talentIds.length}<BuilderRadioGrid label="EQUIPPED TALENT" name={`gear-${slot}-talent`} items={item.talentIds.map((id: string) => talent(id)).filter(Boolean)} value={selection.talentId} onchange={(id) => { selection.talentId = id; selection.talentTier = supportedTalentTier(id, selection.talentTier); }} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if}
{#if selection.talentId && talent(selection.talentId)}<TalentTierChoice name={`gear-${slot}-talent-tier`} talent={talent(selection.talentId)} bind:value={selection.talentTier}/>{/if}
</div>
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional.' : 'Base values scale with item level and use game-matching truncated display values.'}</p>
<div class="builder-range-grid"><RangeValueControl label="BASE HEALTH" min={healthRange.min} max={healthRange.max} provisional={healthRange.provisional} bind:value={selection.health}/>{#if armorRange}<RangeValueControl label="BASE ARMOR" min={armorRange.min} max={armorRange.max} provisional={armorRange.provisional} bind:value={selection.armor}/>{/if}</div>
</section>
<section class="builder-editor-lane" aria-label="Gear attributes and augments">
<div class="builder-section-title"><div><span>CORE ALLOCATION</span><small>SHARED T{item.tier} POOL</small></div><b class:restricted={coreTotal(selection) < gearCore.poolMin || coreTotal(selection) > gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={gearCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, index}{@const rollRange = attributeRange(attribute(roll.statId), 'gear', selection.level)}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== index).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${index + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>OPTIONAL ITEM UPGRADES</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{augment.effect_type === 'Custom' ? `${attribute(augment.stat_attribute_id)?.name} · ${unitValue(augment.value,augment.stat_attribute_id)}` : `+${augment.value}% BASE HEALTH${item.base_armor_min !== null ? ' + ARMOR' : ''}`}</small></span><em>{augment.costs.find((cost: any) => Number(cost.gear_tier) === Number(item.tier))?.unlock_cost ?? '—'} COST · T{item.tier}</em></label>{/each}</div>{/if}
</section>
</div>
{/if}
{:else if activeParts[0] === 'weapon'}
{@const index = Number(activeParts[1])}{@const selection = draft.weapons[index]}{@const item = weaponItem(index)}{@const other = weaponItem(index ? 0 : 1)}
<div class="loadout-editor-head"><div><span>WEAPON SLOT</span><h2>{index ? 'SECONDARY' : 'PRIMARY'} WEAPON</h2></div><b>{item ? item.damage_type : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.weapons} bind:value={selection.itemId} onchange={(id) => configureWeapon(index,id)} disabledIds={other && Number(other.is_exotic) ? data.weapons.filter((entry) => Number(entry.is_exotic)).map((entry) => entry.id) : []} label="WEAPON DEFINITION"/>
<div class="loadout-editor-head"><div><span>WEAPON</span><h2>{index ? 'SECONDARY' : 'PRIMARY'}</h2></div><b>{item ? item.damage_type : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.weapons} bind:value={selection.itemId} onchange={(id) => configureWeapon(index,id)} disabledIds={other && Number(other.is_exotic) ? data.weapons.filter((entry) => Number(entry.is_exotic)).map((entry) => entry.id) : []} label="WEAPON"/>
{#if item}
{#if Number(item.is_exotic) && other && Number(other.is_exotic)}<p class="error">Only one exotic weapon may be equipped. Choose a standard weapon in either slot.</p>{/if}
<div class="builder-weapon-controls">
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((range: any) => ({ value: String(range.tier), label: `T${range.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} required/>
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
</div>
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
{#if range}
<p class:provisional={selection.level < 40} class="builder-scaling-note">{selection.level < 40 ? 'Estimated range: the weapon curve below level 40 is provisional and may change as observations are added.' : 'Damage is calculated from the stored weapon base range at the selected item level. Core attributes use the shared level-50 tier allocation pool.'}</p>
{@const damageRange = weaponStatRange(range,'damage',selection.level)}
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div>
{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{selection.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < weaponCore.poolMin || coreTotal(selection) > weaponCore.poolMax}>{coreTotal(selection)} / {weaponCore.poolMin}–{weaponCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={weaponCore.poolMax} bind:value={selection[key]}/>{/each}</div>
{#if range}{@const damageRange = weaponStatRange(range,'damage',selection.level)}{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
<div class="builder-editor-columns">
<section class="builder-editor-lane" aria-label="Weapon identity and base values">
<div class="builder-weapon-controls">
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((entry: any) => ({ value: String(entry.tier), label: `T${entry.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} required/>
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
</div>
<p class:provisional={selection.level < 40} class="builder-scaling-note">{selection.level < 40 ? 'Estimated range below level 40.' : 'Damage scales with the selected item level.'}</p>
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div>
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
</section>
<section class="builder-editor-lane" aria-label="Weapon attributes and talents">
<div class="builder-section-title"><div><span>CORE ALLOCATION</span><small>SHARED T{selection.tier} POOL</small></div><b class:restricted={coreTotal(selection) < weaponCore.poolMin || coreTotal(selection) > weaponCore.poolMax}>{coreTotal(selection)} / {weaponCore.poolMin}–{weaponCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={weaponCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, rollIndex}{@const rollRange = attributeRange(attribute(roll.statId), 'weapon', selection.level)}<div><BuilderPicker items={weaponAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'weapon',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== rollIndex).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${rollIndex + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
<div class="builder-talent-grids">
{#if item.talent1Ids.length}<section class="builder-talent-slot"><BuilderRadioGrid label="TALENT 1" name={`weapon-${index}-talent-1`} items={item.talent1Ids.map((id: string) => talent(id)).filter(Boolean)} value={selection.talent1Id} onchange={(id) => { selection.talent1Id = id; selection.talent1Tier = supportedTalentTier(id, selection.talent1Tier); }} columns={Math.min(item.talent1Ids.length,4)} searchable={item.talent1Ids.length > 6} required/>{#if selection.talent1Id && talent(selection.talent1Id)}<TalentTierChoice name={`weapon-${index}-talent-1-tier`} talent={talent(selection.talent1Id)} bind:value={selection.talent1Tier}/>{/if}</section>{/if}
{#if item.talent2Ids.length}<section class="builder-talent-slot"><BuilderRadioGrid label="TALENT 2" name={`weapon-${index}-talent-2`} items={item.talent2Ids.map((id: string) => talent(id)).filter(Boolean)} value={selection.talent2Id} onchange={(id) => { selection.talent2Id = id; selection.talent2Tier = supportedTalentTier(id, selection.talent2Tier); }} columns={Math.min(item.talent2Ids.length,4)} searchable={item.talent2Ids.length > 6} required/>{#if selection.talent2Id && talent(selection.talent2Id)}<TalentTierChoice name={`weapon-${index}-talent-2-tier`} talent={talent(selection.talent2Id)} bind:value={selection.talent2Tier}/>{/if}</section>{/if}
{#if item.exotic_talent_id && talent(item.exotic_talent_id)}<section class="builder-talent-slot builder-exotic-talent"><div><ItemIcon src={talent(item.exotic_talent_id).iconUrl} alt=""/><span><small>EXOTIC TALENT</small><b>{talent(item.exotic_talent_id).name}</b></span></div><TalentTierChoice name={`weapon-${index}-exotic-talent-tier`} talent={talent(item.exotic_talent_id)} bind:value={selection.exoticTalentTier}/></section>{/if}
</div>
</section>
</div>
{/if}
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO WEAPON RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, rollIndex}{@const rollRange = attributeRange(attribute(roll.statId), 'weapon', selection.level)}<div><BuilderPicker items={weaponAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'weapon',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== rollIndex).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${rollIndex + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
<div class="builder-talent-grids">
{#if item.talent1Ids.length}<BuilderRadioGrid label="TALENT 1" name={`weapon-${index}-talent-1`} items={item.talent1Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent1Id} columns={Math.min(item.talent1Ids.length,4)} searchable={item.talent1Ids.length > 6} required/>{/if}
{#if item.talent2Ids.length}<BuilderRadioGrid label="TALENT 2" name={`weapon-${index}-talent-2`} items={item.talent2Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent2Id} columns={Math.min(item.talent2Ids.length,4)} searchable={item.talent2Ids.length > 6} required/>{/if}
</div>
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
{/if}
{:else if activeParts[0] === 'chip'}
{@const index = Number(activeParts[1])}{@const chip = draft.chips[index]}{@const item = chipItem(index)}{@const abilityIndex = Math.floor(index / 3)}{@const ability = selectedFocus?.abilities[abilityIndex]}
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1} · CHIP {(index % 3) + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← RETURN TO {ability?.name?.toUpperCase() ?? 'ABILITY'}</button>
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}<p class="error">Select a focus with three configured class abilities before equipping Skill Chips.</p>{/if}
{#if !chipProfileConfigured}<p class="error">SHARED SKILL CHIP PROFILE · CONFIG REQUIRED. Complete every global quality range in ISAC before this loadout can be complete.</p>{/if}
<div class="loadout-editor-head"><div><span>CHIP {(index % 3) + 1} / 3</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← BACK TO ABILITY</button>
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => chipSupportsAbility(entry, ability.id))} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="CHIP SET"/>{:else}<p class="error">Choose a focus with 3 abilities first.</p>{/if}
{#if !chipProfileConfigured}<p class="error">CHIP PROFILE REQUIRED. Configure shared ranges in ISAC.</p>{/if}
{#if item}
<div class="builder-field-grid">
<SegmentedChoice legend="QUALITY" name={`chip-${index}-quality`} options={skillChipQualities.map((quality) => ({ value: quality, label: quality.toUpperCase() }))} value={chip.quality} onchange={(values) => changeChipQuality(index, values[0] as SkillChipQuality)} required/>
<div class="builder-editor-columns builder-chip-editor">
<section class="builder-editor-lane" aria-label="Skill Chip quality and primary Attribute">
<div class="builder-field-grid"><SegmentedChoice legend="QUALITY" name={`chip-${index}-quality`} options={skillChipQualities.map((quality) => ({ value: quality, label: quality.toUpperCase() }))} value={chip.quality} onchange={(values) => changeChipQuality(index, values[0] as SkillChipQuality)} required/></div>
<div class="builder-section-title"><div><span>PRIMARY</span><small>ABILITY ONLY</small></div><b>NOT IN TOTALS</b></div>
<p class="builder-scaling-note">Not included in totals.</p>
<div class="builder-chip-rolls"><div><BuilderPicker items={chipPrimaryAttributes} bind:value={chip.primary.statId} onchange={(id) => setChipRoll(chip.primary,id,'primary',chip.quality)} label="PRIMARY"/>{#if validChipRange(chipRange('primary', chip.primary.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('primary', chip.primary.statId, chip.quality)} max={chipRangeMax('primary', chip.primary.statId, chip.quality)} step={0.01} bind:value={chip.primary.value}/>{:else}<p class="error">PRIMARY RANGE REQUIRED</p>{/if}</div></div>
</section>
<section class="builder-editor-lane" aria-label="Skill Chip secondary Attributes">
<div class="builder-section-title"><div><span>SECONDARIES</span><small>{skillChipSecondaryCapacity[chip.quality]} SLOT{skillChipSecondaryCapacity[chip.quality] === 1 ? '' : 'S'}</small></div></div>
<div class="builder-chip-rolls">{#each chip.secondary as roll, secondaryIndex}<div><BuilderPicker items={chipSecondaryAttributes} bind:value={roll.statId} onchange={(id) => setChipRoll(roll,id,'secondary',chip.quality)} disabledIds={chip.secondary.filter((_,i) => i !== secondaryIndex).map((entry) => entry.statId).filter(Boolean)} label={`SECONDARY ${secondaryIndex + 1}`}/>{#if validChipRange(chipRange('secondary', roll.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('secondary', roll.statId, chip.quality)} max={chipRangeMax('secondary', roll.statId, chip.quality)} step={0.01} bind:value={roll.value}/>{:else}<p class="error">SECONDARY RANGE REQUIRED</p>{/if}</div>{/each}</div>
{#if skillChipSecondaryCapacity[chip.quality] === 0}<p class="builder-scaling-note">NO SECONDARIES</p>{/if}
</section>
</div>
<div class="builder-section-title"><div><span>ABILITY-SCOPED PRIMARY</span><small>{ability?.name?.toUpperCase() ?? 'ASSIGNED ABILITY'} ONLY · EXCLUDED FROM KNOWN AGENT STATS</small></div><b>NOT YET DEFINED</b></div>
<p class="builder-scaling-note">The primary roll belongs to this ability. Its calculation application is NOT YET DEFINED, so it does not contribute to current Agent totals.</p>
<div class="builder-chip-rolls">
<div>
<BuilderPicker items={chipPrimaryAttributes} bind:value={chip.primary.statId} onchange={(id) => setChipRoll(chip.primary,id,'primary',chip.quality)} label="PRIMARY ATTRIBUTE"/>
{#if validChipRange(chipRange('primary', chip.primary.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('primary', chip.primary.statId, chip.quality)} max={chipRangeMax('primary', chip.primary.statId, chip.quality)} step={0.01} bind:value={chip.primary.value}/>{:else}<p class="error">PRIMARY RANGE · CONFIG REQUIRED</p>{/if}
</div>
{#each chip.secondary as roll, secondaryIndex}
<div>
<BuilderPicker items={chipSecondaryAttributes} bind:value={roll.statId} onchange={(id) => setChipRoll(roll,id,'secondary',chip.quality)} disabledIds={chip.secondary.filter((_,i) => i !== secondaryIndex).map((entry) => entry.statId).filter(Boolean)} label={`SECONDARY ATTRIBUTE ${secondaryIndex + 1}`}/>
{#if validChipRange(chipRange('secondary', roll.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('secondary', roll.statId, chip.quality)} max={chipRangeMax('secondary', roll.statId, chip.quality)} step={0.01} bind:value={roll.value}/>{:else}<p class="error">SECONDARY RANGE · CONFIG REQUIRED</p>{/if}
</div>
{/each}
</div>
{#if skillChipSecondaryCapacity[chip.quality] === 0}<p class="builder-scaling-note">STANDARD QUALITY · NO SECONDARY ATTRIBUTES</p>{/if}
{/if}
{:else}
{@const protocol = protocolItem()}
<div class="loadout-editor-head"><div><span>AGENT SYSTEM</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.protocols} bind:value={draft.protocol.itemId} label="OS PROTOCOL"/>
<div class="loadout-editor-head"><div><span>PROTOCOL</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.protocols} bind:value={draft.protocol.itemId} label="PROTOCOL"/>
{#if protocol}<div class="builder-range-grid"><label>PROTOCOL LEVEL <span>01–50</span><input type="number" min="1" max="50" bind:value={draft.protocol.level} onblur={() => draft.protocol.level = clamp(draft.protocol.level,1,50)}/></label><label>TALENT UPGRADES <span>0–5</span><input type="number" min="0" max="5" bind:value={draft.protocol.talentUpgrades} onblur={() => draft.protocol.talentUpgrades = clamp(draft.protocol.talentUpgrades,0,5)}/></label></div><div class="builder-inherited">{#if protocol.primaryEffect}<span><small>{attribute(protocol.primaryEffect.stat_attribute_id)?.name} · LEVEL {draft.protocol.level}</small><b>{unitValue(Number(protocol.primaryEffect.base_value)*(1+(draft.protocol.level-1)/49),protocol.primaryEffect.stat_attribute_id)}</b></span>{/if}{#each protocol.secondaryEffects as effect}<span><small>{attribute(effect.stat_attribute_id)?.name}</small><b>{unitValue(effect.value,effect.stat_attribute_id)}</b></span>{/each}</div>{/if}
{/if}
</section>
+104
View File
@@ -127,6 +127,58 @@ describe('Builder Skill Chip save validation', () => {
expect(mocks.insertBuild).not.toHaveBeenCalled();
});
it('accepts a Skill Chip owned by an explicitly compatible ability', async () => {
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM skill_chip_sets'))
return [
{
id: 'chip-set-1',
specialization_ability_id: 'ability-2',
},
];
if (sql.includes('FROM specialization_ability_skill_chip_links'))
return [
{
ability_id: 'ability-1',
compatible_ability_id: 'ability-2',
},
];
if (sql.includes('FROM specialization_focus_abilities'))
return [{ ability_id: 'ability-1', ability_slot: 1 }];
if (sql.includes('FROM skill_chip_primary_options'))
return [{ stat_attribute_id: primaryStatId }];
if (sql.includes('FROM skill_chip_primary_quality_values'))
return [
{
stat_attribute_id: primaryStatId,
quality: 'Superior',
min_value: 10,
max_value: 20,
},
];
if (sql.includes('FROM skill_chip_secondary_options'))
return secondaryStatIds.map((stat_attribute_id) => ({
stat_attribute_id,
}));
if (sql.includes('FROM skill_chip_secondary_quality_values'))
return secondaryStatIds.map((stat_attribute_id) => ({
stat_attribute_id,
quality: 'Superior',
min_value: 1,
max_value: 5,
}));
return [];
}),
run: mocks.insertBuild,
}));
const result = await runSave(validLoadout());
expect(result).toMatchObject({ buildSaved: true });
expect(mocks.insertBuild).toHaveBeenCalledOnce();
});
it.each([
{
label: 'primary',
@@ -183,4 +235,56 @@ describe('Builder Skill Chip save validation', () => {
});
expect(mocks.insertBuild).toHaveBeenCalledOnce();
});
it('rejects an equipment Talent tier without canonical tier data', async () => {
const loadout = createDraft();
loadout.buildName = 'Invalid Talent Tier';
loadout.gear.Holster.itemId = 'gear-1';
loadout.gear.Holster.talentId = 'talent-1';
loadout.gear.Holster.talentTier = 2;
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM gear_talents'))
return [{ gear_id: 'gear-1', talent_id: 'talent-1' }];
if (sql.includes('FROM talent_tiers'))
return [{ talent_id: 'talent-1', tier: 'I' }];
return [];
}),
run: mocks.insertBuild,
}));
const result = await runSave(loadout);
expect(result).toMatchObject({
status: 400,
data: { buildError: 'Holster Talent T2 is not configured.' },
});
expect(mocks.insertBuild).not.toHaveBeenCalled();
});
it('saves an equipment Talent with an authored Tier II', async () => {
const loadout = createDraft();
loadout.buildName = 'Tier Two Talent';
loadout.gear.Holster.itemId = 'gear-1';
loadout.gear.Holster.talentId = 'talent-1';
loadout.gear.Holster.talentTier = 2;
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM gear_talents'))
return [{ gear_id: 'gear-1', talent_id: 'talent-1' }];
if (sql.includes('FROM talent_tiers'))
return [
{ talent_id: 'talent-1', tier: 'I' },
{ talent_id: 'talent-1', tier: 'II' },
];
return [];
}),
run: mocks.insertBuild,
}));
const result = await runSave(loadout);
expect(result).toMatchObject({ buildSaved: true });
expect(mocks.insertBuild).toHaveBeenCalledOnce();
});
});
+3 -2
View File
@@ -10,6 +10,7 @@
const chip = (id: string) => data.chips.find((item) => item.id === id);
const protocol = (id: string) => data.protocols.find((item) => item.id === id);
const brand = (id: string) => data.brands.find((item) => item.id === id);
const talent = (id: string) => data.talents.find((item) => item.id === id);
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
const rollLine = (rolls: Array<{ statId: string; value: number }>) => rolls
@@ -56,11 +57,11 @@
<div class="shared-build-grid">
<section>
<header><span>WEAPONS</span><b>02 SLOTS</b></header>
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · ${item.damage_type} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.damage)} DAMAGE</b><b>{item.rpm} RPM</b><b>{item.magazine} ROUNDS</b><b>{item.reload_speed}s RELOAD</b></div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · ${item.damage_type} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.damage)} DAMAGE</b><b>{item.rpm} RPM</b><b>{item.magazine} ROUNDS</b><b>{item.reload_speed}s RELOAD</b>{#if selection.talent1Id}<b>{talent(selection.talent1Id)?.name ?? 'TALENT 1'} · T{selection.talent1Tier}</b>{/if}{#if selection.talent2Id}<b>{talent(selection.talent2Id)?.name ?? 'TALENT 2'} · T{selection.talent2Tier}</b>{/if}{#if item.exotic_talent_id}<b>{talent(item.exotic_talent_id)?.name ?? 'EXOTIC TALENT'} · T{selection.exoticTalentTier}</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
</section>
<section>
<header><span>GEAR</span><b>06 SLOTS</b></header>
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.health)} HEALTH</b>{#if item.base_armor_min !== null}<b>{number.format(selection.armor)} ARMOR</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.health)} HEALTH</b>{#if item.base_armor_min !== null}<b>{number.format(selection.armor)} ARMOR</b>{/if}{#if selection.talentId}<b>{talent(selection.talentId)?.name ?? 'TALENT'} · T{selection.talentTier}</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
</section>
<section>
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header>
+52 -85
View File
@@ -6,6 +6,10 @@ import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPagination, getRegistrySort } from '$lib/server/pagination';
import { recordIsacAudit } from '$lib/server/audit';
import {
AuthorityChangeError,
applyAuthorityChange,
} from '$lib/server/auth/authority-change';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
@@ -228,6 +232,7 @@ export const actions: Actions = {
targetId = String(event.entity_id);
let current: Record<string, unknown> | null = null;
let restored: Record<string, unknown> | null = null;
let auditRecordedInTransaction = false;
try {
if (action.startsWith('attribute.')) {
current =
@@ -349,88 +354,47 @@ export const actions: Actions = {
permissions: permissionNames,
};
} else if (action === 'access.updated') {
const currentRoles = await (
await database.prepare(
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
)
).all(targetId);
const currentPermissions = await (
await database.prepare(
'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id',
)
).all(targetId);
current = {
roleIds: currentRoles.map((row) => row.role_id),
permissionIds: currentPermissions.map((row) => row.permission_id),
};
if (comparable(current) !== comparable(after))
return fail(409, {
revertError:
'The user’s access changed after this event. Reversion was blocked.',
});
const roleIds = Array.isArray(before.roleIds)
? before.roleIds.map(String)
: [];
const permissionIds = Array.isArray(before.permissionIds)
? before.permissionIds.map(String)
: [];
const restoresAdminRole = roleIds.includes('role-admin');
const restoresSystemPermission = permissionIds.includes(
'permission-system-admin',
);
if ((restoresAdminRole || restoresSystemPermission) && !isFullAdmin)
return fail(403, {
revertError:
'Only a full administrator can restore administrator access.',
});
const roleCount = roleIds.length
? Number(
(
await (
await database.prepare(
`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`,
)
).get(...roleIds)
)?.count,
)
: 0;
const permissionCount = permissionIds.length
? Number(
(
await (
await database.prepare(
`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
)
).get(...permissionIds)
)?.count,
)
: 0;
if (
roleCount !== roleIds.length ||
permissionCount !== permissionIds.length
!Array.isArray(after.roleIds) ||
!Array.isArray(after.permissionIds)
)
return fail(409, {
revertError: 'A previous role or permission no longer exists.',
revertError: 'The stored access snapshot is incomplete.',
});
await (
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
).run(targetId);
await (
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
).run(targetId);
for (const roleId of roleIds)
await (
await database.prepare(
'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)',
)
).run(targetId, roleId);
for (const permissionId of permissionIds)
await (
await database.prepare(
'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)',
)
).run(targetId, permissionId);
restored = { roleIds, permissionIds };
const expectedCurrent = {
roleIds: after.roleIds.map(String),
permissionIds: after.permissionIds.map(String),
};
const result = await applyAuthorityChange({
database,
operator,
targetId,
roleIds,
permissionIds,
expectedCurrent,
audit: {
actorId: operator.id,
category: 'audit_control',
action: 'audit.reverted',
entityId: eventId,
before: current,
after: {
originalEventId: eventId,
originalAction: action,
targetId,
restored: { roleIds, permissionIds },
},
},
});
current = result.before;
restored = result.after;
auditRecordedInTransaction = true;
} else {
return fail(400, {
revertError:
@@ -438,25 +402,28 @@ export const actions: Actions = {
});
}
} catch (error) {
if (error instanceof AuthorityChangeError)
return fail(error.status, { revertError: error.message });
if (error && typeof error === 'object' && 'status' in error) throw error;
return fail(409, {
revertError:
'The previous state could not be restored because related data has changed.',
});
}
await recordIsacAudit({
actorId: operator.id,
category: 'audit_control',
action: 'audit.reverted',
entityId: eventId,
before: current,
after: {
originalEventId: eventId,
originalAction: action,
targetId,
restored,
},
});
if (!auditRecordedInTransaction)
await recordIsacAudit({
actorId: operator.id,
category: 'audit_control',
action: 'audit.reverted',
entityId: eventId,
before: current,
after: {
originalEventId: eventId,
originalAction: action,
targetId,
restored,
},
});
return { reverted: true, resultToken: randomUUID() };
},
};
+100
View File
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
database: { prepare: vi.fn() },
getDatabase: vi.fn(),
getPermissions: vi.fn(),
requirePermission: vi.fn(),
getSessionUser: vi.fn(),
applyAuthorityChange: vi.fn(),
recordIsacAudit: vi.fn(),
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: mocks.getSessionUser,
}));
vi.mock('$lib/server/auth/authorization', () => ({
getPermissions: mocks.getPermissions,
requirePermission: mocks.requirePermission,
}));
vi.mock('$lib/server/auth/authority-change', () => ({
applyAuthorityChange: mocks.applyAuthorityChange,
AuthorityChangeError: class extends Error {
status = 409 as const;
},
}));
vi.mock('$lib/server/database', () => ({
getDatabase: mocks.getDatabase,
}));
vi.mock('$lib/server/pagination', () => ({
getPagination: vi.fn(),
getRegistrySort: vi.fn(),
}));
vi.mock('$lib/server/audit', () => ({
recordIsacAudit: mocks.recordIsacAudit,
}));
import { actions } from './+page.server';
describe('ISAC access audit reversion', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getSessionUser.mockResolvedValue({ id: 'operator-1' });
mocks.getPermissions.mockResolvedValue(['*']);
mocks.requirePermission.mockImplementation(async (user) => user);
mocks.getDatabase.mockResolvedValue(mocks.database);
mocks.applyAuthorityChange.mockResolvedValue({
before: { roleIds: ['role-admin'], permissionIds: [] },
after: { roleIds: [], permissionIds: [] },
sessionsRevoked: 1,
});
mocks.database.prepare.mockImplementation((sql: string) => ({
get: vi.fn(async () => {
if (sql.includes('SELECT * FROM audit_log'))
return {
id: 'event-1',
action: 'access.updated',
entity_id: 'target-1',
before_json: JSON.stringify({
roleIds: ['role-admin'],
permissionIds: [],
}),
after_json: JSON.stringify({ roleIds: [], permissionIds: [] }),
};
return null;
}),
}));
});
it('delegates access reversion to the shared authority operation', async () => {
const form = new FormData();
form.set('eventId', 'event-1');
const result = await actions.revert?.({
cookies: {},
request: new Request('http://localhost/isac/audit?/revert', {
method: 'POST',
body: form,
}),
} as never);
expect(result).toMatchObject({ reverted: true });
expect(mocks.applyAuthorityChange).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'target-1',
roleIds: ['role-admin'],
permissionIds: [],
expectedCurrent: { roleIds: [], permissionIds: [] },
audit: expect.objectContaining({
action: 'audit.reverted',
entityId: 'event-1',
}),
}),
);
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
});
@@ -98,6 +98,14 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order',
)
).all(id),
skillChipLinks: await (
await database.prepare(
`SELECT ability_id,compatible_ability_id
FROM specialization_ability_skill_chip_links
WHERE ability_id=? OR compatible_ability_id=?
ORDER BY ability_id,compatible_ability_id`,
)
).all(id, id),
};
}
if (proposal.action === 'saveCombatEffect') {
+239 -121
View File
@@ -1,5 +1,6 @@
import {
getPermissions,
hasRecentAal2,
requirePermission,
} from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
@@ -13,6 +14,10 @@ import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { recordIsacAudit } from '$lib/server/audit';
import {
countActiveFullAdministrators,
isMfaReady,
} from '$lib/server/auth/bootstrap';
const filters = ['isac', 'data', 'users', 'audit', 'system'];
const seededRoleIds = new Set([
@@ -158,8 +163,6 @@ export const actions: Actions = {
await getSessionUser(cookies),
'users.roles',
);
const operatorPermissions = await getPermissions(operator);
const canManageSystemAdmin = operatorPermissions.includes('*');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '')
@@ -178,106 +181,180 @@ export const actions: Actions = {
roleError: 'Descriptions must be 240 characters or fewer.',
});
const database = await getDatabase();
const existing = id
? await (
await database.prepare(
'SELECT id,name,description FROM roles WHERE id=?',
)
).get(id)
: null;
if (id && !existing) return fail(404, { roleError: 'Role not found.' });
if (id === 'role-admin' && !canManageSystemAdmin) {
return fail(403, {
roleError: 'Only a full administrator can modify the core Admin role.',
});
}
if (
await (
await database.prepare(
'SELECT id FROM roles WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
) {
return fail(409, { roleError: 'A role with that name already exists.' });
}
const validPermissions = permissionIds.length
? await (
await database.prepare(
`SELECT id,name FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
)
).all(...permissionIds)
: [];
if (validPermissions.length !== permissionIds.length)
return fail(400, { roleError: 'One or more permissions are invalid.' });
if (
!canManageSystemAdmin &&
validPermissions.some((permission) => permission.name === 'system.admin')
) {
return fail(403, {
roleError:
'Only a full administrator can grant unrestricted system access.',
});
}
const roleId = id || randomUUID();
const beforeLinks = id
? await (
await database.prepare(`SELECT permissions.name FROM role_permissions
JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`)
).all(id)
: [];
if (id)
await (
await database.prepare(
'UPDATE roles SET name=?,description=? WHERE id=?',
)
).run(name, description || null, id);
else
await (
await database.prepare(
'INSERT INTO roles(id,name,description) VALUES(?,?,?)',
)
).run(roleId, name, description || null);
await (
await database.prepare('DELETE FROM role_permissions WHERE role_id=?')
).run(roleId);
for (const permissionId of permissionIds) {
await (
await database.prepare(
'INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)',
)
).run(roleId, permissionId);
}
await recordIsacAudit({
actorId: operator.id,
category: 'role_management',
action: id ? 'role.updated' : 'role.created',
entityId: roleId,
before: id
? {
name: existing?.name,
description: existing?.description,
permissions: beforeLinks.map((row) => row.name),
}
: null,
after: {
name,
description: description || null,
permissions: validPermissions.map((row) => row.name),
},
});
return {
roleSaved: true,
roleMode: id ? 'edit' : 'add',
resultToken: randomUUID(),
await database.exec('BEGIN IMMEDIATE');
let transactionOpen = true;
const rollbackAndFail = async (
status: number,
data: { roleError: string },
) => {
await database.exec('ROLLBACK');
transactionOpen = false;
return fail(status, data);
};
try {
const operatorPermissions = await getPermissions(operator);
const canManageSystemAdmin = operatorPermissions.includes('*');
const existing = id
? await (
await database.prepare(
'SELECT id,name,description FROM roles WHERE id=?',
)
).get(id)
: null;
if (id && !existing)
return rollbackAndFail(404, { roleError: 'Role not found.' });
if (id === 'role-admin' && !canManageSystemAdmin)
return rollbackAndFail(403, {
roleError:
'Only a full administrator can modify the core Admin role.',
});
if (
await (
await database.prepare(
'SELECT id FROM roles WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
)
return rollbackAndFail(409, {
roleError: 'A role with that name already exists.',
});
const validPermissions = permissionIds.length
? await (
await database.prepare(
`SELECT id,name FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
)
).all(...permissionIds)
: [];
if (validPermissions.length !== permissionIds.length)
return rollbackAndFail(400, {
roleError: 'One or more permissions are invalid.',
});
if (
!canManageSystemAdmin &&
validPermissions.some(
(permission) => permission.name === 'system.admin',
)
)
return rollbackAndFail(403, {
roleError:
'Only a full administrator can grant unrestricted system access.',
});
const hadSystemAdmin = id
? Boolean(
await (
await database.prepare(`
SELECT 1 FROM role_permissions
JOIN permissions ON permissions.id=role_permissions.permission_id
WHERE role_permissions.role_id=? AND permissions.name='system.admin'
`)
).get(id),
)
: false;
const hasSystemAdmin = validPermissions.some(
(permission) => permission.name === 'system.admin',
);
const changesAdministratorAuthority =
id === 'role-admin' || hadSystemAdmin !== hasSystemAdmin;
if (changesAdministratorAuthority && !hasRecentAal2(operator))
return rollbackAndFail(403, {
roleError:
'Recent AAL2 authentication is required for administrator changes.',
});
const roleId = id || randomUUID();
const beforeLinks = id
? await (
await database.prepare(`SELECT permissions.name FROM role_permissions
JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`)
).all(id)
: [];
const authorityTargets =
id && (id === 'role-admin' || hadSystemAdmin || hasSystemAdmin)
? await (
await database.prepare(
'SELECT user_id FROM user_roles WHERE role_id=? ORDER BY user_id',
)
).all(id)
: [];
if (hasSystemAdmin && !hadSystemAdmin) {
for (const target of authorityTargets) {
if (!(await isMfaReady(database, String(target.user_id))))
return rollbackAndFail(400, {
roleError:
'Every account assigned this administrator permission must have an active TOTP factor and an unused recovery code.',
});
}
}
if (id)
await (
await database.prepare(
'UPDATE roles SET name=?,description=? WHERE id=?',
)
).run(name, description || null, id);
else
await (
await database.prepare(
'INSERT INTO roles(id,name,description) VALUES(?,?,?)',
)
).run(roleId, name, description || null);
await (
await database.prepare('DELETE FROM role_permissions WHERE role_id=?')
).run(roleId);
for (const permissionId of permissionIds) {
await (
await database.prepare(
'INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)',
)
).run(roleId, permissionId);
}
if (hadSystemAdmin !== hasSystemAdmin) {
for (const target of authorityTargets)
await (
await database.prepare('DELETE FROM sessions WHERE user_id=?')
).run(String(target.user_id));
}
if ((await countActiveFullAdministrators(database)) < 1)
return rollbackAndFail(409, {
roleError: 'The final active full administrator cannot be removed.',
});
await recordIsacAudit({
actorId: operator.id,
category: 'role_management',
action: id ? 'role.updated' : 'role.created',
entityId: roleId,
before: id
? {
name: existing?.name,
description: existing?.description,
permissions: beforeLinks.map((row) => row.name),
}
: null,
after: {
name,
description: description || null,
permissions: validPermissions.map((row) => row.name),
},
database,
});
await database.exec('COMMIT');
transactionOpen = false;
return {
roleSaved: true,
roleMode: id ? 'edit' : 'add',
resultToken: randomUUID(),
};
} catch (error) {
if (transactionOpen) {
transactionOpen = false;
await database.exec('ROLLBACK');
}
throw error;
}
},
delete: async ({ cookies, request }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'users.roles',
);
const operatorPermissions = await getPermissions(operator);
const canManageSystemAdmin = operatorPermissions.includes('*');
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const confirmation = String(form.get('confirmation') ?? '')
@@ -289,39 +366,66 @@ export const actions: Actions = {
roleDeleteError: 'The core Admin role cannot be deleted.',
});
}
if (seededRoleIds.has(id) && !canManageSystemAdmin) {
return fail(403, {
roleDeleteError:
'Only a full administrator can delete a seeded system role.',
});
}
const database = await getDatabase();
const role = await (
await database.prepare('SELECT id,name,description FROM roles WHERE id=?')
).get(id);
if (!role) return fail(404, { roleDeleteError: 'Role not found.' });
if (confirmation !== String(role.name)) {
return fail(400, {
roleDeleteError: 'Enter the exact role name to confirm deletion.',
});
}
const [permissions, assignedUsers] = await Promise.all([
(
await database.prepare(`SELECT permissions.name FROM role_permissions
await database.exec('BEGIN IMMEDIATE');
let transactionOpen = true;
const rollbackAndFail = async (
status: number,
data: { roleDeleteError: string },
) => {
await database.exec('ROLLBACK');
transactionOpen = false;
return fail(status, data);
};
try {
const canManageSystemAdmin = (await getPermissions(operator)).includes(
'*',
);
if (seededRoleIds.has(id) && !canManageSystemAdmin)
return rollbackAndFail(403, {
roleDeleteError:
'Only a full administrator can delete a seeded system role.',
});
const role = await (
await database.prepare(
'SELECT id,name,description FROM roles WHERE id=?',
)
).get(id);
if (!role)
return rollbackAndFail(404, { roleDeleteError: 'Role not found.' });
if (confirmation !== String(role.name))
return rollbackAndFail(400, {
roleDeleteError: 'Enter the exact role name to confirm deletion.',
});
const grantsSystemAdmin = Boolean(
await (
await database.prepare(`
SELECT 1 FROM role_permissions
JOIN permissions ON permissions.id=role_permissions.permission_id
WHERE role_permissions.role_id=? AND permissions.name='system.admin'
`)
).get(id),
);
if (grantsSystemAdmin && !hasRecentAal2(operator))
return rollbackAndFail(403, {
roleDeleteError:
'Recent AAL2 authentication is required for administrator changes.',
});
const [permissions, assignedUsers] = await Promise.all([
(
await database.prepare(`SELECT permissions.name FROM role_permissions
JOIN permissions ON permissions.id=role_permissions.permission_id
WHERE role_permissions.role_id=? ORDER BY permissions.name COLLATE NOCASE`)
).all(id),
(
await database.prepare(`SELECT users.id,
).all(id),
(
await database.prepare(`SELECT users.id,
COALESCE(user_profiles.display_name,users.username) AS display_name
FROM user_roles JOIN users ON users.id=user_roles.user_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
WHERE user_roles.role_id=?
ORDER BY display_name COLLATE NOCASE,users.id`)
).all(id),
]);
await database.exec('BEGIN IMMEDIATE');
try {
).all(id),
]);
if (seededRoleIds.has(id)) {
await (
await database.prepare(`INSERT INTO deleted_roles(role_id,deleted_at,deleted_by)
@@ -342,6 +446,15 @@ export const actions: Actions = {
).run(id);
if (!deleted.changes)
throw new Error('The role changed before it could be deleted.');
for (const target of assignedUsers)
await (
await database.prepare('DELETE FROM sessions WHERE user_id=?')
).run(String(target.id));
if ((await countActiveFullAdministrators(database)) < 1)
return rollbackAndFail(409, {
roleDeleteError:
'The final active full administrator cannot be removed.',
});
await recordIsacAudit({
actorId: operator.id,
category: 'role_management',
@@ -361,10 +474,15 @@ export const actions: Actions = {
systemRole: seededRoleIds.has(id),
removedAssignmentCount: assignedUsers.length,
},
database,
});
await database.exec('COMMIT');
transactionOpen = false;
} catch (error) {
await database.exec('ROLLBACK');
if (transactionOpen) {
transactionOpen = false;
await database.exec('ROLLBACK');
}
throw error;
}
return { roleDeleted: true, resultToken: randomUUID() };
@@ -0,0 +1,158 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
database: {
prepare: vi.fn(),
exec: vi.fn(),
},
getDatabase: vi.fn(),
getPermissions: vi.fn(),
hasRecentAal2: vi.fn(),
requirePermission: vi.fn(),
getSessionUser: vi.fn(),
recordIsacAudit: vi.fn(),
countActiveFullAdministrators: vi.fn(),
runs: [] as unknown[][],
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: mocks.getSessionUser,
}));
vi.mock('$lib/server/auth/authorization', () => ({
getPermissions: mocks.getPermissions,
hasRecentAal2: mocks.hasRecentAal2,
requirePermission: mocks.requirePermission,
}));
vi.mock('$lib/server/auth/bootstrap', () => ({
countActiveFullAdministrators: mocks.countActiveFullAdministrators,
}));
vi.mock('$lib/server/database', () => ({
getDatabase: mocks.getDatabase,
}));
vi.mock('$lib/server/audit', () => ({
recordIsacAudit: mocks.recordIsacAudit,
}));
import { actions } from './+page.server';
const postRole = (id = '', permissions: string[] = []) => {
const form = new FormData();
form.set('id', id);
form.set('name', 'Field Operators');
form.set('description', 'Ordinary access role');
for (const permission of permissions)
form.append('permissionIds', permission);
return new Request('http://localhost/isac/roles?/save', {
method: 'POST',
body: form,
});
};
const postDelete = () => {
const form = new FormData();
form.set('id', 'role-user-operator');
form.set('confirmation', 'Existing');
return new Request('http://localhost/isac/roles?/delete', {
method: 'POST',
body: form,
});
};
describe('ISAC role authority actions', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.runs.length = 0;
mocks.getSessionUser.mockResolvedValue({ id: 'operator-1' });
mocks.getPermissions.mockResolvedValue(['users.roles']);
mocks.hasRecentAal2.mockReturnValue(true);
mocks.requirePermission.mockImplementation(async (user) => user);
mocks.getDatabase.mockResolvedValue(mocks.database);
mocks.countActiveFullAdministrators.mockResolvedValue(1);
mocks.database.prepare.mockImplementation((sql: string) => ({
get: vi.fn(async (...args: unknown[]) => {
if (sql.includes('SELECT id,name,description FROM roles WHERE id=?'))
return args[0]
? { id: args[0], name: 'Existing', description: null }
: null;
if (sql.includes('SELECT id FROM roles WHERE name=')) return null;
if (sql.includes('COUNT(DISTINCT users.id)')) return { count: 1 };
return null;
}),
all: vi.fn(async (...args: unknown[]) => {
if (sql.includes('SELECT id,name FROM permissions'))
return args.map((id) => ({ id, name: 'users.view' }));
if (sql.includes('SELECT permissions.name FROM role_permissions'))
return [];
return [];
}),
run: vi.fn(async (...args: unknown[]) => {
mocks.runs.push(args);
return { changes: 1 };
}),
}));
});
it('rolls back role writes when audit recording fails', async () => {
mocks.recordIsacAudit.mockRejectedValueOnce(new Error('audit failed'));
await expect(
actions.save?.({
cookies: {},
request: postRole('', ['permission-users-view']),
} as never),
).rejects.toThrow('audit failed');
expect(mocks.database.exec.mock.calls).toEqual([
['BEGIN IMMEDIATE'],
['ROLLBACK'],
]);
expect(mocks.database.exec).not.toHaveBeenCalledWith('COMMIT');
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
expect.objectContaining({ database: mocks.database }),
);
});
it('rejects a role mutation that would leave no active full administrator', async () => {
mocks.countActiveFullAdministrators.mockResolvedValue(0);
const result = await actions.save?.({
cookies: {},
request: postRole('', ['permission-users-view']),
} as never);
expect(result).toMatchObject({ status: 409 });
expect(mocks.database.exec.mock.calls).toEqual([
['BEGIN IMMEDIATE'],
['ROLLBACK'],
]);
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('keeps role deletion and its audit evidence in one transaction', async () => {
mocks.getPermissions.mockResolvedValue(['*']);
const result = await actions.delete?.({
cookies: {},
request: postDelete(),
} as never);
expect(result).toMatchObject({
roleDeleted: true,
resultToken: expect.any(String),
});
expect(mocks.database.exec.mock.calls).toEqual([
['BEGIN IMMEDIATE'],
['COMMIT'],
]);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: 'role.deleted',
database: mocks.database,
}),
);
});
});
+2 -2
View File
@@ -124,7 +124,7 @@
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b>STANDARD</b><b>SPECIALIZED</b><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Shared profiles define primary and secondary ranges.</small></div></div>
<section class="skill-chip-ability-assignment">
<div class="selection-head"><div><span>SPECIALIZATION ABILITY</span><small>EXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}</b></div>
<div class="selection-head"><div><span>OWNING ABILITY</span><small>THREE ACTIVE DEFINITIONS · ABILITY SHARING IS CONFIGURED IN SPECIALIZATIONS</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 OWNED` : 'REQUIRED'}</b></div>
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
</section>
@@ -169,7 +169,7 @@
<div class="dashboard-table">
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><span>ROLL PROFILE</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.sets as item}
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>OWNING ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first ability-linked Skill Chip definition.</span></div>{/each}
</div>
@@ -187,6 +187,14 @@ async function focusSnapshot(database: Database, id: string) {
async function abilitySnapshot(database: Database, id: string) {
return {
record: await auditRecord('specialization_abilities', id),
skillChipLinks: await (
await database.prepare(
`SELECT ability_id,compatible_ability_id
FROM specialization_ability_skill_chip_links
WHERE ability_id=? OR compatible_ability_id=?
ORDER BY ability_id,compatible_ability_id`,
)
).all(id, id),
attack:
(await (
await database.prepare(
@@ -398,6 +406,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'cooldownSeconds',
'effectsJson',
'attackJson',
'skillChipCompatibleAbilityId',
],
},
{
@@ -470,6 +479,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
specializations,
focuses,
abilities,
abilitySkillChipLinks,
abilityAttacks,
abilityEffects,
focusAbilities,
@@ -506,6 +516,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'SELECT * FROM specialization_abilities ORDER BY specialization_id,active DESC,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_ability_skill_chip_links ORDER BY ability_id,compatible_ability_id',
)
).all(),
(
await database.prepare('SELECT * FROM specialization_ability_attacks')
).all(),
@@ -672,6 +687,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
})),
...focuses,
];
const linkedAbilityById = new Map<string, string>();
for (const link of abilitySkillChipLinks) {
linkedAbilityById.set(
String(link.ability_id),
String(link.compatible_ability_id),
);
linkedAbilityById.set(
String(link.compatible_ability_id),
String(link.ability_id),
);
}
const loadedAbilities = [
...pendingAbilities.map((item) => ({
...item,
@@ -680,6 +706,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
cooldown_seconds: item.cooldownSeconds || null,
attack: item.attackJson ? JSON.parse(String(item.attackJson)) : null,
effects: parseJsonArray<AbilityEffectInput>(item.effectsJson),
skillChipCompatibleAbilityId: item.skillChipCompatibleAbilityId || '',
active: 1,
pending: true,
skillChipCount: 0,
@@ -698,6 +725,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
chipCounts.find((item) => item.specialization_ability_id === ability.id)
?.count ?? 0,
),
skillChipCompatibleAbilityId:
linkedAbilityById.get(String(ability.id)) ?? '',
iconUrl: iconUrl('specialization_ability', String(ability.id)),
renderUrl: renderUrl('specialization_ability', String(ability.id)),
})),
@@ -1160,7 +1189,12 @@ export const actions: Actions = {
);
const form = await request.formData();
const id = String(form.get('id') ?? '');
const abilityId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const specializationId = String(form.get('specializationId') ?? '');
const skillChipCompatibleAbilityId = String(
form.get('skillChipCompatibleAbilityId') ?? '',
);
const name = cleanName(form.get('name'));
const description = cleanName(form.get('description'));
const durationSeconds = optionalPositive(form.get('durationSeconds'));
@@ -1312,6 +1346,46 @@ export const actions: Actions = {
) {
return fail(400, { abilityError: 'Choose an active specialization.' });
}
if (skillChipCompatibleAbilityId) {
if (skillChipCompatibleAbilityId === abilityId) {
return fail(400, {
abilityError: 'An ability cannot share Skill Chips with itself.',
});
}
const compatibleAbility = await (
await database.prepare(
'SELECT id,specialization_id FROM specialization_abilities WHERE id=? AND active=1',
)
).get(skillChipCompatibleAbilityId);
if (
!compatibleAbility ||
String(compatibleAbility.specialization_id) !== specializationId
) {
return fail(400, {
abilityError:
'Choose an active Skill Chip-compatible ability from the same specialization.',
});
}
const conflictingLink = await (
await database.prepare(
`SELECT ability_id,compatible_ability_id
FROM specialization_ability_skill_chip_links
WHERE (ability_id=? OR compatible_ability_id=?)
AND ability_id<>? AND compatible_ability_id<>?`,
)
).get(
skillChipCompatibleAbilityId,
skillChipCompatibleAbilityId,
abilityId,
abilityId,
);
if (conflictingLink) {
return fail(409, {
abilityError:
'Each ability can share one Skill Chip pool. Clear the existing link first.',
});
}
}
if (
!(await validateAttributeIds(
database,
@@ -1387,8 +1461,6 @@ export const actions: Actions = {
: 'The ability icon could not be processed.',
});
}
const abilityId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await abilitySnapshot(database, id) : null;
const iconChanged = Boolean(iconMutation.output || iconMutation.remove);
const previousIcon = iconChanged
@@ -1426,6 +1498,24 @@ export const actions: Actions = {
now,
);
}
await (
await database.prepare(
`DELETE FROM specialization_ability_skill_chip_links
WHERE ability_id=? OR compatible_ability_id=?`,
)
).run(abilityId, abilityId);
if (skillChipCompatibleAbilityId) {
const [leftAbilityId, rightAbilityId] = [
abilityId,
skillChipCompatibleAbilityId,
].sort();
await (
await database.prepare(
`INSERT INTO specialization_ability_skill_chip_links
(ability_id,compatible_ability_id) VALUES(?,?)`,
)
).run(leftAbilityId, rightAbilityId);
}
await (
await database.prepare(
'DELETE FROM specialization_ability_effects WHERE ability_id=?',
@@ -2326,6 +2416,12 @@ export const actions: Actions = {
'UPDATE specialization_abilities SET active=0,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await (
await database.prepare(
`DELETE FROM specialization_ability_skill_chip_links
WHERE ability_id=? OR compatible_ability_id=?`,
)
).run(id, id);
if (ability)
await refreshSpecializationFocuses(
database,
+34 -3
View File
@@ -32,6 +32,7 @@
description: string;
durationSeconds: number | string;
cooldownSeconds: number | string;
skillChipCompatibleAbilityId: string;
attack: {
enabled: boolean;
baseDamage: number | string;
@@ -97,7 +98,7 @@
id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1
});
const blankAbility = (specializationId = ''): AbilityEditor => ({
id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '',
id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '', skillChipCompatibleAbilityId: '',
attack: {
enabled: false, baseDamage: '', damageType: 'Blast', delivery: 'direct',
hitsPerActivation: 1, tickIntervalSeconds: '', damageDurationSeconds: '',
@@ -161,10 +162,25 @@
const abilityById = (id: string) => data.abilities.find((item: any) => item.id === id);
const combatEffectById = (id: string) => data.combatEffects.find((item: any) => item.id === id);
const abilityChipCount = (ability: any) => Number(ability?.skillChipCount ?? 0);
const abilityEligibleChipCount = (ability: any) => abilityChipCount(ability)
+ abilityChipCount(abilityById(String(ability?.skillChipCompatibleAbilityId ?? '')));
const focusList = (specializationId: string) => data.focuses.filter((focus: any) => focus.specialization_id === specializationId);
const abilityList = (specializationId: string) => data.abilities.filter((ability: any) =>
ability.specialization_id === specializationId && Number(ability.active) === 1
);
const skillChipCompatibilityOptions = (editor: AbilityEditor) => abilityList(editor.specializationId)
.filter((ability: any) => ability.id !== editor.id)
.map((ability: any) => ({
...ability,
detail: `${abilityChipCount(ability)} OWN CHIP SETS`,
disabled: Boolean(
ability.skillChipCompatibleAbilityId
&& ability.skillChipCompatibleAbilityId !== editor.id
),
disabledReason: ability.skillChipCompatibleAbilityId && ability.skillChipCompatibleAbilityId !== editor.id
? 'ALREADY SHARES ANOTHER POOL'
: ''
}));
const combatEffectList = (specializationId: string) => data.combatEffects.filter((effect: any) =>
effect.specialization_id === specializationId && Number(effect.active) === 1
);
@@ -234,6 +250,7 @@
description: item.description,
durationSeconds: item.duration_seconds ?? '',
cooldownSeconds: item.cooldown_seconds ?? '',
skillChipCompatibleAbilityId: item.skillChipCompatibleAbilityId ?? '',
attack: item.attack ? {
enabled: true,
baseDamage: item.attack.base_damage,
@@ -499,6 +516,20 @@
<label class="field">COOLDOWN · SECONDS<input name="cooldownSeconds" type="number" min="0.01" step="any" bind:value={abilityEditing.cooldownSeconds} placeholder="OPTIONAL"/></label>
<div><span>AVAILABILITY</span><strong>IMMEDIATE · NO POINT OR MATERIAL COST</strong><small>Assigned ability slots 1 and 3 are mirrored into the top-left of the focus tree.</small></div>
</div>
<div class="specialization-skill-chip-sharing">
<div class="selection-head"><div><span>SHARED SKILL CHIP POOL</span><small>OPTIONAL · SAME SPECIALIZATION · 3 EQUIPPED SLOTS, UP TO 6 ELIGIBLE SETS</small></div><b>{abilityEditing.skillChipCompatibleAbilityId ? 'SHARED' : 'ISOLATED'}</b></div>
<SearchableAssignment
items={skillChipCompatibilityOptions(abilityEditing)}
mode="single"
compact
fieldName="skillChipCompatibleAbilityId"
subjectLabel="COMPATIBLE ABILITY"
searchPlaceholder="Search this specialization…"
emptySelectionLabel="NO SHARED SKILL CHIP POOL"
bind:value={abilityEditing.skillChipCompatibleAbilityId}
/>
<p class="configuration-note">Link only abilities that can equip each other's Skill Chips in game. Unlinked abilities remain isolated.</p>
</div>
<div class="specialization-attack-profile">
<div class="selection-head"><div><span>INTRINSIC ATTACK PROFILE</span><small>UNMODIFIED DAMAGE BEFORE ENGINEERING, ATTRIBUTES, TALENTS, AND TREE UNLOCKS</small></div><b>{abilityEditing.attack.enabled ? 'DAMAGING ABILITY' : 'NO DIRECT DAMAGE'}</b></div>
<SegmentedChoice legend="DAMAGE MODEL" name="ability-damage-model" options={[{value:'disabled',label:'NO DAMAGE'},{value:'enabled',label:'MODELS DAMAGE'}]} value={abilityEditing.attack.enabled ? 'enabled' : 'disabled'} onchange={(values) => setAbilityAttackEnabled(values[0] === 'enabled')}/>
@@ -728,7 +759,7 @@
<span>ABILITY {slot} · {slot === 2 ? 'COMBAT SKILLS' : 'TREE + COMBAT SKILLS'}</span>
<strong>{ability?.name ?? 'NOT ASSIGNED'}</strong>
{#if ability}
<small class:ready={abilityChipCount(ability) === 3} class="ability-chip-readiness">{abilityChipCount(ability)} / 3 SKILL CHIPS · {abilityChipCount(ability) === 3 ? 'READY' : 'INCOMPLETE'}</small>
<small class:ready={abilityChipCount(ability) === 3} class="ability-chip-readiness">{abilityChipCount(ability)} OWNED · {abilityEligibleChipCount(ability)} ELIGIBLE · {abilityChipCount(ability) === 3 ? 'READY' : 'INCOMPLETE'}</small>
{/if}
<p>{ability?.description ?? 'Assign an active class ability to complete this focus.'}</p>
</div>
@@ -746,7 +777,7 @@
{#each data.abilities.filter((ability: any) => ability.specialization_id === selectedSpecialization.id) as ability}
<article class:archived={Number(ability.active) === 0}>
<ItemIcon src={ability.iconUrl} alt={ability.name}/>
<div><strong>{ability.name}</strong><small>{Number(ability.active) === 1 ? `${abilityChipCount(ability)} / 3 SKILL CHIPS` : 'ARCHIVED CLASS ABILITY'}</small></div>
<div><strong>{ability.name}</strong><small>{Number(ability.active) === 1 ? `${abilityChipCount(ability)} OWNED · ${abilityEligibleChipCount(ability)} ELIGIBLE` : 'ARCHIVED CLASS ABILITY'}</small></div>
<button class="secondary specialization-mutation" onclick={() => editAbility(ability)}>{Number(ability.active) === 1 ? 'EDIT' : 'REVIEW + RESTORE'} →</button>
</article>
{:else}
@@ -0,0 +1,227 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => {
const links: Array<{ ability_id: string; compatible_ability_id: string }> =
[];
return {
links,
auditRecord: vi.fn(async () => ({
id: 'ability-1',
specialization_id: 'specialization-1',
name: 'Tactical Link',
})),
recordIsacAudit: vi.fn(),
database: {
exec: vi.fn(),
prepare: vi.fn(),
},
};
});
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
}));
vi.mock('$lib/server/auth/authorization', () => ({
requireDataView: vi.fn(async (user) => user),
requirePermission: vi.fn(async (user) => user),
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
vi.mock('$lib/server/audit', () => ({
auditRecord: mocks.auditRecord,
recordIsacAudit: mocks.recordIsacAudit,
}));
vi.mock('$lib/server/data-moderation', () => ({
deserializeProposal: vi.fn(),
getPendingEntities: vi.fn(async () => []),
}));
vi.mock('$lib/server/game-data-icons', () => ({
applyIconMutation: vi.fn(),
gameDataIconStorageDirectory: vi.fn(),
gameDataIconMetadata: vi.fn(async () => null),
gameDataIconUrl: vi.fn(() => ''),
GameDataIconValidationError: class extends Error {},
readGameDataIcon: vi.fn(async () => null),
readIconMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/managed-images', () => ({
applyGameDataRender: vi.fn(),
ManagedImageValidationError: class extends Error {},
managedImageUrl: vi.fn(() => ''),
processManagedImage: vi.fn(),
readGameDataRenderMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/specialization-readiness', () => ({
refreshSpecializationFocusReady: vi.fn(),
refreshSpecializationFocuses: vi.fn(),
}));
vi.mock('$lib/server/conditional-effects', () => ({
conditionalRuleSnapshot: vi.fn(),
loadConditionalRuleDraft: vi.fn(),
parseConditionalRulePayload: vi.fn(),
saveConditionalRuleInTransaction: vi.fn(),
}));
import { actions } from './+page.server';
const saveRequest = (compatibleAbilityId: string) => {
const form = new FormData();
form.set('id', 'ability-1');
form.set('specializationId', 'specialization-1');
form.set('skillChipCompatibleAbilityId', compatibleAbilityId);
form.set('name', 'Tactical Link');
form.set('description', 'Deploy a tactical support link.');
form.set('effectsJson', '[]');
form.set('attackJson', '{"enabled":false}');
return new Request('http://localhost/isac/specializations?/saveAbility', {
method: 'POST',
body: form,
});
};
const runSave = (compatibleAbilityId: string) =>
actions.saveAbility?.({
cookies: {} as never,
request: saveRequest(compatibleAbilityId),
} as never);
const runArchive = () => {
const form = new FormData();
form.set('id', 'ability-1');
return actions.archiveAbility?.({
cookies: {} as never,
request: new Request(
'http://localhost/isac/specializations?/archiveAbility',
{ method: 'POST', body: form },
),
} as never);
};
describe('Specialization ability Skill Chip sharing', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.links.length = 0;
mocks.database.prepare.mockImplementation(async (sql: string) => ({
get: vi.fn(async (...args: unknown[]) => {
if (
sql.includes('SELECT specialization_id FROM specialization_abilities')
)
return { specialization_id: 'specialization-1' };
if (sql.includes('SELECT id FROM specializations'))
return { id: 'specialization-1' };
if (
sql.includes(
'SELECT id,specialization_id FROM specialization_abilities',
)
)
return {
id: String(args[0]),
specialization_id:
args[0] === 'cross-specialization-ability'
? 'specialization-2'
: 'specialization-1',
};
if (sql.includes('FROM specialization_ability_skill_chip_links'))
return null;
if (
sql.includes('FROM specialization_abilities WHERE specialization_id=')
)
return null;
if (sql.includes('FROM specialization_ability_attacks')) return null;
if (sql.includes('FROM managed_images')) return null;
return null;
}),
all: vi.fn(async () => {
if (sql.includes('FROM specialization_ability_skill_chip_links'))
return [...mocks.links];
return [];
}),
run: vi.fn(async (...args: unknown[]) => {
if (sql.includes('DELETE FROM specialization_ability_skill_chip_links'))
mocks.links.length = 0;
if (sql.includes('INSERT INTO specialization_ability_skill_chip_links'))
mocks.links.push({
ability_id: String(args[0]),
compatible_ability_id: String(args[1]),
});
}),
}));
});
it('saves and audits an explicit same-specialization ability pair', async () => {
const result = await runSave('ability-2');
expect(result).toMatchObject({
abilitySaved: true,
abilityMode: 'edit',
resultToken: expect.any(String),
});
expect(mocks.links).toEqual([
{ ability_id: 'ability-1', compatible_ability_id: 'ability-2' },
]);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
actorId: 'operator-1',
category: 'game_data',
action: 'specialization_ability.updated',
entityId: 'ability-1',
before: expect.objectContaining({ record: expect.any(Object) }),
after: expect.objectContaining({
skillChipLinks: [
{ ability_id: 'ability-1', compatible_ability_id: 'ability-2' },
],
}),
});
});
it('rejects cross-specialization sharing without writing or auditing', async () => {
const result = await runSave('cross-specialization-ability');
expect(result).toMatchObject({
status: 400,
data: {
abilityError:
'Choose an active Skill Chip-compatible ability from the same specialization.',
},
});
expect(mocks.database.exec).not.toHaveBeenCalled();
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('clears the relationship when either ability is archived', async () => {
mocks.links.push({
ability_id: 'ability-1',
compatible_ability_id: 'ability-2',
});
const result = await runArchive();
expect(result).toMatchObject({
abilityArchived: true,
resultToken: expect.any(String),
});
expect(mocks.links).toEqual([]);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'operator-1',
category: 'game_data',
action: 'specialization_ability.archived',
entityId: 'ability-1',
before: expect.objectContaining({
skillChipLinks: [
{ ability_id: 'ability-1', compatible_ability_id: 'ability-2' },
],
}),
after: expect.objectContaining({ skillChipLinks: [] }),
}),
);
});
});
+37 -116
View File
@@ -2,9 +2,17 @@ import {
getPermissions,
requirePermission,
} from '$lib/server/auth/authorization';
import { isFullAdministrator } from '$lib/server/auth/bootstrap';
import {
AuthorityChangeError,
applyAuthorityChange,
} from '$lib/server/auth/authority-change';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { softDeleteAccount } from '$lib/server/auth/account-deletion';
import {
LastAdministratorDeletionError,
softDeleteAccount,
} from '$lib/server/auth/account-deletion';
import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
@@ -286,105 +294,20 @@ export const actions: Actions = {
...new Set(form.getAll('permissionIds').map(String)),
];
const database = await getDatabase();
const target = await (
await database.prepare('SELECT role FROM users WHERE id=?')
).get(params.id);
if (!target) return fail(404, { accessError: 'User not found.' });
const operatorPermissions = await getPermissions(operator);
const targetAdminRole = await (
await database.prepare(
`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`,
)
).get(params.id);
const changesAdminRole =
roleIds.includes('role-admin') !== Boolean(targetAdminRole);
if (
(target.role === 'admin' || changesAdminRole) &&
!operatorPermissions.includes('*')
) {
return fail(403, {
accessError:
'Only an administrator can grant, remove, or modify administrator access.',
try {
await applyAuthorityChange({
database,
operator,
targetId: params.id,
roleIds,
permissionIds,
});
return { accessSaved: true };
} catch (error) {
if (error instanceof AuthorityChangeError)
return fail(error.status, { accessError: error.message });
throw error;
}
const validRoles = roleIds.length
? Number(
(
await (
await database.prepare(
`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`,
)
).get(...roleIds)
)?.count,
)
: 0;
const validPermissions = permissionIds.length
? Number(
(
await (
await database.prepare(
`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
)
).get(...permissionIds)
)?.count,
)
: 0;
if (
validRoles !== roleIds.length ||
validPermissions !== permissionIds.length
)
return fail(400, {
accessError: 'One or more access selections are invalid.',
});
const before = {
roleIds: (
await (
await database.prepare(
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
)
).all(params.id)
).map((row) => row.role_id),
permissionIds: (
await (
await database.prepare(
'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id',
)
).all(params.id)
).map((row) => row.permission_id),
};
await (
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
).run(params.id);
await (
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
).run(params.id);
for (const roleId of roleIds)
await (
await database.prepare(
'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)',
)
).run(params.id, roleId);
for (const permissionId of permissionIds)
await (
await database.prepare(
'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)',
)
).run(params.id, permissionId);
await (
await database.prepare(
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
)
).run(
randomUUID(),
operator.id,
'user_access',
params.id,
'access.updated',
JSON.stringify(before),
JSON.stringify({ roleIds, permissionIds }),
new Date().toISOString(),
);
return { accessSaved: true };
},
moderate: async ({ cookies, request, params }) => {
const operator = await requirePermission(
@@ -414,16 +337,11 @@ export const actions: Actions = {
const now = new Date().toISOString(),
database = await getDatabase();
const target = await (
await database.prepare('SELECT role FROM users WHERE id=?')
await database.prepare('SELECT id FROM users WHERE id=?')
).get(params.id);
if (!target) return fail(404, { moderationError: 'User not found.' });
const targetAdminRole = await (
await database.prepare(
`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`,
)
).get(params.id);
if (
(target.role === 'admin' || targetAdminRole) &&
(await isFullAdministrator(database, params.id)) &&
!(await getPermissions(operator)).includes('*')
) {
return fail(403, {
@@ -484,16 +402,11 @@ export const actions: Actions = {
return fail(400, { adminDeleteError: 'Provide a deletion reason.' });
const database = await getDatabase();
const target = await (
await database.prepare('SELECT role FROM users WHERE id=?')
await database.prepare('SELECT id FROM users WHERE id=?')
).get(params.id);
if (!target) return fail(404, { adminDeleteError: 'User not found.' });
const targetAdminRole = await (
await database.prepare(
`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`,
)
).get(params.id);
if (
(target.role === 'admin' || targetAdminRole) &&
(await isFullAdministrator(database, params.id)) &&
!(await getPermissions(operator)).includes('*')
) {
return fail(403, {
@@ -501,10 +414,18 @@ export const actions: Actions = {
'Only an administrator can delete another administrator.',
});
}
if (!(await softDeleteAccount(database, params.id, operator.id, reason))) {
return fail(409, {
adminDeleteError: 'This account has already been deleted.',
});
try {
if (!(await softDeleteAccount(database, params.id, operator.id, reason)))
return fail(409, {
adminDeleteError: 'This account has already been deleted.',
});
} catch (error) {
if (error instanceof LastAdministratorDeletionError)
return fail(409, {
adminDeleteError:
'The final active full administrator cannot be deleted.',
});
throw error;
}
return { accountDeleted: true };
},
@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => {
class TestAuthorityChangeError extends Error {
readonly status: 400 | 403 | 404 | 409;
constructor(status: 400 | 403 | 404 | 409, message: string) {
super(message);
this.status = status;
}
}
return {
getDatabase: vi.fn(),
getPermissions: vi.fn(),
requirePermission: vi.fn(),
getSessionUser: vi.fn(),
applyAuthorityChange: vi.fn(),
AuthorityChangeError: TestAuthorityChangeError,
};
});
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: mocks.getSessionUser,
}));
vi.mock('$lib/server/auth/authorization', () => ({
getPermissions: mocks.getPermissions,
requirePermission: mocks.requirePermission,
}));
vi.mock('$lib/server/auth/bootstrap', () => ({
isFullAdministrator: vi.fn(),
}));
vi.mock('$lib/server/auth/authority-change', () => ({
applyAuthorityChange: mocks.applyAuthorityChange,
AuthorityChangeError: mocks.AuthorityChangeError,
}));
vi.mock('$lib/server/database', () => ({
getDatabase: mocks.getDatabase,
}));
vi.mock('$lib/server/audit', () => ({
recordIsacAudit: vi.fn(),
}));
vi.mock('$lib/server/auth/account-deletion', () => ({
LastAdministratorDeletionError: class extends Error {},
softDeleteAccount: vi.fn(),
}));
vi.mock('$lib/server/avatars', () => ({
deleteUploadedAvatar: vi.fn(),
}));
vi.mock('$lib/server/auth/methods', () => ({
canRemoveAuthenticationMethod: vi.fn(),
getAuthenticationMethods: vi.fn(),
}));
vi.mock('$lib/server/pagination', () => ({
getPagination: vi.fn(),
}));
vi.mock('$lib/server/notifications', () => ({
createNotification: vi.fn(),
}));
import { actions } from './+page.server';
const postAccess = (roleIds: string[] = [], permissionIds: string[] = []) => {
const form = new FormData();
for (const roleId of roleIds) form.append('roleIds', roleId);
for (const permissionId of permissionIds)
form.append('permissionIds', permissionId);
return new Request('http://localhost/isac/users/operator-1?/setAccess', {
method: 'POST',
body: form,
});
};
describe('ISAC user access action', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getSessionUser.mockResolvedValue({ id: 'operator-1' });
mocks.requirePermission.mockImplementation(async (user) => user);
mocks.getDatabase.mockResolvedValue({});
mocks.getPermissions.mockResolvedValue(['users.roles']);
mocks.applyAuthorityChange.mockResolvedValue({
before: { roleIds: [], permissionIds: [] },
after: { roleIds: ['role-user-operator'], permissionIds: [] },
sessionsRevoked: 0,
});
});
it('posts self-escalation to the shared authority operation and returns its denial', async () => {
mocks.applyAuthorityChange.mockRejectedValueOnce(
new mocks.AuthorityChangeError(
403,
'Only a full administrator can grant administrator access.',
),
);
const result = await actions.setAccess?.({
cookies: {},
params: { id: 'operator-1' },
request: postAccess([], ['permission-system-admin']),
} as never);
expect(result).toMatchObject({ status: 403 });
expect(mocks.applyAuthorityChange).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'operator-1',
roleIds: [],
permissionIds: ['permission-system-admin'],
}),
);
});
it('delegates ordinary role changes without bypassing the shared operation', async () => {
const result = await actions.setAccess?.({
cookies: {},
params: { id: 'operator-1' },
request: postAccess(['role-user-operator']),
} as never);
expect(result).toEqual({ accessSaved: true });
expect(mocks.applyAuthorityChange).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'operator-1',
roleIds: ['role-user-operator'],
permissionIds: [],
}),
);
});
});
+14 -16
View File
@@ -1,11 +1,7 @@
import {
createSession,
destroySession,
recordLogin,
} from '$lib/server/auth/session';
import { destroySession, recordLogin } from '$lib/server/auth/session';
import { hashPassword, verifyPassword } from '$lib/server/auth/password';
import { getDatabase } from '$lib/server/database';
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
import { completePrimaryAuthentication } from '$lib/server/auth/primary-authentication';
import { createMagicLinkToken } from '$lib/server/auth/magic-link';
import {
emailDeliveryConfigured,
@@ -215,7 +211,6 @@ export const actions: Actions = {
now,
);
await insertUserProfile(database, id, displayName, now);
await ensureBootstrapAdmin(database, id);
await triggerAutomatedSystemMessages({
eventKey: 'account.created',
userId: id,
@@ -223,13 +218,15 @@ export const actions: Actions = {
}).catch((error) =>
console.error('Unable to deliver new-agent welcome message', error),
);
const sessionId = await createSession(cookies, id);
await recordLogin(id, 'password', true, {
const authentication = await completePrimaryAuthentication({
database,
cookies,
userId: id,
provider: 'password',
request,
getClientAddress,
sessionId,
});
redirect(303, '/account');
redirect(303, authentication.redirectTo);
},
login: async ({ request, cookies, getClientAddress }) => {
const form = await request.formData();
@@ -273,14 +270,15 @@ export const actions: Actions = {
username,
});
}
await ensureBootstrapAdmin(database, String(user.id));
const sessionId = await createSession(cookies, String(user.id));
await recordLogin(String(user.id), 'password', true, {
const authentication = await completePrimaryAuthentication({
database,
cookies,
userId: String(user.id),
provider: 'password',
request,
getClientAddress,
sessionId,
});
redirect(303, '/account');
redirect(303, authentication.redirectTo);
},
logout: async ({ cookies }) => {
await destroySession(cookies);