Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/src/common/data-injection.tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export enum UseCaseType {
AGENTS_VALIDATE_TABLE_AI_REQUEST = 'AGENTS_VALIDATE_TABLE_AI_REQUEST',
AGENTS_VALIDATE_CONNECTION_EDIT = 'AGENTS_VALIDATE_CONNECTION_EDIT',
AGENTS_SET_PUBLIC_PERMISSIONS = 'AGENTS_SET_PUBLIC_PERMISSIONS',
AGENTS_SET_SITE_RUNTIME_POLICY = 'AGENTS_SET_SITE_RUNTIME_POLICY',
AGENTS_GET_AI_CONNECTION_CONTEXT = 'AGENTS_GET_AI_CONNECTION_CONTEXT',
AGENTS_GET_AI_CONNECTION_TABLES = 'AGENTS_GET_AI_CONNECTION_TABLES',
AGENTS_GET_AI_TABLE_STRUCTURE = 'AGENTS_GET_AI_TABLE_STRUCTURE',
Expand Down
10 changes: 10 additions & 0 deletions backend/src/entities/connection/connection.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ export class ConnectionEntity {
@Column({ type: 'text', nullable: true, default: null })
public_cedar_policy?: string | null;

// Server-side site data contract for the SiteNova generated-site runtime API (plan 13 §4),
// served to universal-backend inside the internal credentials response. Presence marks the
// connection as backing a real generated site (Step 2a allow-list); its shape (auth/write/
// owned-read rules) is consumed by universal-backend's SiteContractService in Step 2b. NULL for
// every ordinary admin-panel connection.
// jsonb value typed `Record<string, any>` to match the existing jsonb columns
// (e.g. TableFiltersEntity.filters) and keep TypeORM's update() deep-partial typings happy.
@Column({ type: 'jsonb', nullable: true, default: null })
site_runtime_policy?: Record<string, any> | null;

/**
* Non-persisted flag indicating whether credentials are currently in decrypted state.
* Used by @BeforeUpdate to decide whether encryption is needed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ export interface IConnectionRepository {

updateConnectionPublicCedarPolicy(connectionId: string, publicCedarPolicy: string | null): Promise<void>;

getConnectionSiteRuntimePolicy(connectionId: string): Promise<Record<string, unknown> | null>;

updateConnectionSiteRuntimePolicy(
connectionId: string,
siteRuntimePolicy: Record<string, unknown> | null,
): Promise<void>;

findOneAgentConnectionByToken(connectionToken: string): Promise<ConnectionEntity | null>;

decryptConnectionField(field: string): string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,25 @@ export const customConnectionRepositoryExtension: IConnectionRepository &
.execute();
},

async getConnectionSiteRuntimePolicy(connectionId: string): Promise<Record<string, unknown> | null> {
const connection = await this.createQueryBuilder('connection')
.select(['connection.id', 'connection.site_runtime_policy'])
.where('connection.id = :connectionId', { connectionId })
.getOne();
return connection?.site_runtime_policy ?? null;
},

async updateConnectionSiteRuntimePolicy(
connectionId: string,
siteRuntimePolicy: Record<string, unknown> | null,
): Promise<void> {
await this.createQueryBuilder()
.update(ConnectionEntity)
.set({ site_runtime_policy: siteRuntimePolicy })
.where('id = :connectionId', { connectionId })
.execute();
},

async isUserFromConnection(userId: string, connectionId: string): Promise<boolean> {
const qb = this.createQueryBuilder('connection')
.leftJoin('connection.groups', 'group')
Expand Down
3 changes: 3 additions & 0 deletions backend/src/exceptions/text/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ export const Messages = {
PUBLIC_POLICY_ACTION_NOT_ALLOWED:
'Public permissions may only grant QueryTable (table:query) and ColumnRead (column:read)',
PUBLIC_ACCESS_NOT_CONFIGURED: 'Public access is not configured for this connection',
PUBLIC_GRANT_ON_AUTH_TABLE_NOT_ALLOWED:
"Public read cannot be granted on the site's visitor accounts table or its credential column",
SITE_RUNTIME_POLICY_INVALID: 'Site runtime policy must be a JSON object',
CSV_EXPORT_FAILED: 'CSV export failed',
CSV_EXPORT_DISABLED: 'CSV export is disabled',
CSV_IMPORT_FAILED: 'CSV import failed',
Expand Down
25 changes: 25 additions & 0 deletions backend/src/microservices/agents-microservice/agents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
CompanySubscriptionInfoRO,
PermissionAllowedRO,
PublicPermissionsRO,
SiteRuntimePolicyRO,
ValidatedUserTokenRO,
} from './data-structures/agents-responses.ds.js';
import {
Expand All @@ -28,6 +29,7 @@ import {
import { ValidateConnectionEditDto, ValidateTableAiRequestDto, ValidateUserTokenDto } from './dto/agents-auth.dtos.js';
import { GetCompanySubscriptionInfoDto } from './dto/agents-company.dtos.js';
import { SetAgentsPublicPermissionsDto } from './dto/agents-public-permissions.dtos.js';
import { SetSiteRuntimePolicyDto } from './dto/agents-site-runtime-policy.dtos.js';
import {
IExecuteAiAggregationPipeline,
IExecuteAiRawQuery,
Expand All @@ -38,6 +40,7 @@ import {
IGetCompanySubscriptionInfo,
IScanAndCreateSettings,
ISetPublicPermissions,
ISetSiteRuntimePolicy,
IValidateConnectionEdit,
IValidateTableAiRequest,
IValidateUserToken,
Expand All @@ -59,6 +62,8 @@ export class AgentsController {
private readonly validateConnectionEditUseCase: IValidateConnectionEdit,
@Inject(UseCaseType.AGENTS_SET_PUBLIC_PERMISSIONS)
private readonly setPublicPermissionsUseCase: ISetPublicPermissions,
@Inject(UseCaseType.AGENTS_SET_SITE_RUNTIME_POLICY)
private readonly setSiteRuntimePolicyUseCase: ISetSiteRuntimePolicy,
@Inject(UseCaseType.AGENTS_GET_AI_CONNECTION_CONTEXT)
private readonly getAiConnectionContextUseCase: IGetAiConnectionContext,
@Inject(UseCaseType.AGENTS_GET_AI_CONNECTION_TABLES)
Expand Down Expand Up @@ -127,6 +132,26 @@ export class AgentsController {
);
}

@ApiOperation({
summary: 'Write the site data contract (runtime manifest) for a generated site — agent finalize flow',
description:
'Re-checks Cedar connection:edit for the given user, then stores the manifest on the connection ' +
'(site_runtime_policy, replacing any previous one). universal-backend enforces it at the generated-site ' +
'runtime: auth-table pinning, write allow-lists with ownership, owner-scoped reads (plan 13 Step 2b).',
})
@ApiResponse({ status: 201, type: SiteRuntimePolicyRO })
@ApiBody({ type: SetSiteRuntimePolicyDto })
@Post('/connection/site-runtime-policy/:connectionId')
public async setSiteRuntimePolicy(
@SlugUuid('connectionId') connectionId: string,
@Body() body: SetSiteRuntimePolicyDto,
): Promise<SiteRuntimePolicyRO> {
return await this.setSiteRuntimePolicyUseCase.execute(
{ connectionId, userId: body.userId, policy: body.policy },
InTransactionEnum.OFF,
);
}

@ApiOperation({ summary: 'Get AI-relevant connection context (type, schema, MongoDB flag)' })
@ApiResponse({ status: 201, type: AiConnectionContextRO })
@ApiBody({ type: AiDataRequestBaseDto })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { GetAiTableStructureUseCase } from './use-cases/get-ai-table-structure.u
import { GetCompanySubscriptionInfoUseCase } from './use-cases/get-company-subscription-info.use.case.js';
import { ScanAndCreateSettingsUseCase } from './use-cases/scan-and-create-settings.use.case.js';
import { SetPublicPermissionsUseCase } from './use-cases/set-public-permissions.use.case.js';
import { SetSiteRuntimePolicyUseCase } from './use-cases/set-site-runtime-policy.use.case.js';
import { ValidateConnectionEditUseCase } from './use-cases/validate-connection-edit.use.case.js';
import { ValidateTableAiRequestUseCase } from './use-cases/validate-table-ai-request.use.case.js';
import { ValidateUserTokenUseCase } from './use-cases/validate-user-token.use.case.js';
Expand Down Expand Up @@ -40,6 +41,10 @@ import { ValidateUserTokenUseCase } from './use-cases/validate-user-token.use.ca
provide: UseCaseType.AGENTS_SET_PUBLIC_PERMISSIONS,
useClass: SetPublicPermissionsUseCase,
},
{
provide: UseCaseType.AGENTS_SET_SITE_RUNTIME_POLICY,
useClass: SetSiteRuntimePolicyUseCase,
},
{
provide: UseCaseType.AGENTS_GET_AI_CONNECTION_CONTEXT,
useClass: GetAiConnectionContextUseCase,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ export class PublicPermissionsRO {
tables: Array<{ tableName: string; readableColumns?: Array<string> }>;
}

export class SiteRuntimePolicyRO {
@ApiProperty({
type: 'object',
additionalProperties: true,
description: 'The site runtime policy (data contract) now stored on the connection.',
})
policy: Record<string, unknown>;
}

export class AiConnectionContextRO {
@ApiProperty()
connectionId: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export class SetPublicPermissionsDs {
mode: 'merge' | 'replace';
}

export class SetSiteRuntimePolicyDs {
userId: string;
connectionId: string;
policy: Record<string, unknown>;
}

export class AiDataRequestDs {
connectionId: string;
userId: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsObject, IsString } from 'class-validator';

export class SetSiteRuntimePolicyDto {
@ApiProperty({ description: 'The user on whose behalf the manifest is written — must hold connection:edit (Cedar).' })
@IsNotEmpty()
@IsString()
userId: string;

@ApiProperty({
type: 'object',
additionalProperties: true,
description:
'The site data contract (plan 13 §4): {auth, ownedRead, write}. Replaces the stored policy wholesale — ' +
'the generation agent always proposes the complete manifest it derived from the schema it created. ' +
'universal-backend parses it fail-closed, so unknown keys are inert and missing sections grant nothing.',
})
@IsObject()
policy: Record<string, unknown>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
GetCompanySubscriptionInfoDs,
ScanAndCreateSettingsDs,
SetPublicPermissionsDs,
SetSiteRuntimePolicyDs,
ValidateConnectionEditDs,
ValidateTableAiRequestDs,
} from '../data-structures/agents.ds.js';
Expand All @@ -19,6 +20,7 @@ import {
CompanySubscriptionInfoRO,
PermissionAllowedRO,
PublicPermissionsRO,
SiteRuntimePolicyRO,
ValidatedUserTokenRO,
} from '../data-structures/agents-responses.ds.js';

Expand All @@ -38,6 +40,10 @@ export interface ISetPublicPermissions {
execute(inputData: SetPublicPermissionsDs, inTransaction: InTransactionEnum): Promise<PublicPermissionsRO>;
}

export interface ISetSiteRuntimePolicy {
execute(inputData: SetSiteRuntimePolicyDs, inTransaction: InTransactionEnum): Promise<SiteRuntimePolicyRO>;
}

export interface IGetAiConnectionContext {
execute(inputData: AiDataRequestDs, inTransaction: InTransactionEnum): Promise<AiConnectionContextRO>;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ForbiddenException, Inject, Injectable, Logger, Scope } from '@nestjs/common';
import { ForbiddenException, HttpException, HttpStatus, Inject, Injectable, Logger, Scope } from '@nestjs/common';
import AbstractUseCase from '../../../common/abstract-use.case.js';
import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js';
import { BaseType } from '../../../common/data-injection.tokens.js';
Expand Down Expand Up @@ -80,6 +80,8 @@ export class SetPublicPermissionsUseCase
throw new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS);
}

await this.refuseGrantOnSiteAuthTable(connectionId, userId, tables);

let effective: Array<IPublicTablePermission> = tables;
if (mode !== 'replace') {
const existing = await this.cedarAuthService.getPublicPermissions(connectionId);
Comment on lines +83 to 87
Expand All @@ -94,4 +96,35 @@ export class SetPublicPermissionsUseCase
);
return { enabled: saved.enabled, tables: saved.tables };
}

// "Never publicly grant the users/auth table or a password column" used to be generation-prompt
// text only (plan 13 §3) — a misbehaving agent could still expose every visitor account to the
// anonymous internet. With the site manifest stored server-side (Step 2b) the rule is enforced
// here: a requested table matching the manifest's auth table is refused outright, and so is any
// column whitelist naming the manifest's password column (that column holds credentials wherever
// it appears). Connections without a manifest (plain admin-panel connections) are unaffected.
private async refuseGrantOnSiteAuthTable(
connectionId: string,
userId: string,
tables: Array<IPublicTablePermission>,
): Promise<void> {
const policy = await this._dbContext.connectionRepository.getConnectionSiteRuntimePolicy(connectionId);
const auth = policy?.auth as { tableName?: unknown; passwordField?: unknown } | undefined;
const authTable = typeof auth?.tableName === 'string' ? auth.tableName : null;
const passwordField = typeof auth?.passwordField === 'string' ? auth.passwordField : null;
if (!authTable) {
return;
}
for (const table of tables) {
const namesAuthTable = table.tableName === authTable;
const namesPasswordColumn = Boolean(passwordField && table.readableColumns?.includes(passwordField));
if (namesAuthTable || namesPasswordColumn) {
Comment on lines +120 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate relevant files"
fd -a 'set-public-permissions.use.case.ts|cedar-policy-generator.ts|agents-use-cases.interface.ts|agents-.*\.ds.ts|agents-.*\.ds$.|agents-responses.ds.ts' backend/src || true

echo
echo "Search for SetPublicPermissionsDs definition/usages"
rg -n "SetPublicPermissionsDs|SetPublicPermissions|set-public-permissions|readableColumns|passwordField|authTable" backend/src -S | head -200

echo
echo "Inspect target use-case lines"
cat -n backend/src/microservices/agents-microservice/use-cases/set-public-permissions.use.case.ts | sed -n '1,180p'

echo
echo "Inspect public permission interface"
cat -n backend/src/entities/cedar-authorization/cedar-policy-generator.ts | sed -n '1,120p'

Repository: rocket-admin/rocketadmin

Length of output: 27009


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Inspect public permissions persistence/validation"
cat -n backend/src/entities/cedar-authorization/cedar-authorization.service.ts | sed -n '300,430p'
cat -n backend/src/microservices/agents-microservice/data-structures/agents.ds.ts | sed -n '1,80p'
cat -n backend/src/microservices/agents-microservice/data-structures/agents-responses.ds.ts | sed -n '1,70p'
cat -n backend/src/microservices/agents-microservice/data-structures/agents.use.cases.interface.ts | sed -n '1,90p'

echo
echo "Search for table metadata calls from agents use case or controller"
rg -n "getConnectionTable|getTable|connectionTable|structure|columns|getReadableColumns|set_public_read_permissions|SetPublicPermissionsDto|SetPublicPermissionsDs" backend/src/microservices/agents-microservice backend/src/entities/cedar-authorization -S

Repository: rocket-admin/rocketadmin

Length of output: 9646


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Agents use-case interface"
cat -n backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts | sed -n '1,90p'

echo
echo "Agents controller relevant flow"
cat -n backend/src/microservices/agents-microservice/agents.controller.ts | sed -n '1,120p'

echo
echo "Validation helpers and policy references"
cat -n backend/src/entities/cedar-authorization/cedar-authorization.service.ts | sed -n '430,540p'
cat -n backend/src/entities/cedar-authorization/cedar-authorization.service.ts | sed -n '1,120p'

echo
echo "Target path and deterministic behavior probe from source text"
python3 - <<'PY'
import pathlib, re

p = pathlib.Path('backend/src/microservices/agents-microservice/use-cases/set-public-permissions.use.case.ts')
s = p.read_text()

checks = [
  ('refuseGrantOnSiteAuthTable checks existing requests only', re.search(r'for\s+const\s+table\s+of\s+tables\s*\{', s) is not None),
  ('passwordField check only through explicit readableColumns includes', bool(re.search(r'Boolean\(passwordField\s*&&\s*table\.readableColumns\?\.includes\(passwordField\)\)', s))),
  ('wildcard/empty readableColumns expands to all columns in generator', bool(re.search(r'else\s*\{\s*policies\.push\(\s*`permit\(\n\s*principal,\n\s*action\s*==\s*RocketAdmin::Action::"column:read",\n\s*resource\s+in\s+\$\{tableRef\}\n\);', s))),
]
for name, ok in checks:
    print(f'{name}: {ok}')
PY

Repository: rocket-admin/rocketadmin

Length of output: 19972


Sensitive Data Exposure (CWE-200): Exposure of Sensitive Information to an Unauthorized Actor

Reachability: Internal

Reachability path
● Entry
  backend/src/microservices/agents-microservice/agents.module.ts:42
  SetPublicPermissionsUseCase
│
▼
● Sink
  backend/src/microservices/agents-microservice/use-cases/set-public-permissions.use.case.ts

Block wildcard public grants that can include the password field.

readableColumns omitted or empty generates an all-column public grant. The current check only rejects an explicit passwordField entry, so a non-auth table containing that credential field can still be exposed. Expand readableColumns before this check, or reject wildcard grants while a site policy defines passwordField.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/src/microservices/agents-microservice/use-cases/set-public-permissions.use.case.ts`
around lines 120 - 121, Update the permission validation around
namesPasswordColumn in the public-grant flow to reject wildcard or omitted
readableColumns whenever the site policy defines passwordField, since those
grants include every column. Preserve the existing explicit password-column and
auth-table checks, while allowing grants only when their expanded columns cannot
expose the configured credential field.

this.logger.warn(
`Public-read grant REFUSED (site auth table/credential column): connection=${connectionId} ` +
`user=${userId} table=${table.tableName} authTable=${authTable}`,
);
throw new HttpException({ message: Messages.PUBLIC_GRANT_ON_AUTH_TABLE_NOT_ALLOWED }, HttpStatus.BAD_REQUEST);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { ForbiddenException, HttpException, HttpStatus, Inject, Injectable, Logger, Scope } from '@nestjs/common';
import AbstractUseCase from '../../../common/abstract-use.case.js';
import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js';
import { BaseType } from '../../../common/data-injection.tokens.js';
import { CedarAction } from '../../../entities/cedar-authorization/cedar-action-map.js';
import { CedarAuthorizationService } from '../../../entities/cedar-authorization/cedar-authorization.service.js';
import { Messages } from '../../../exceptions/text/messages.js';
import { SetSiteRuntimePolicyDs } from '../data-structures/agents.ds.js';
import { SiteRuntimePolicyRO } from '../data-structures/agents-responses.ds.js';
import { ISetSiteRuntimePolicy } from './agents-use-cases.interface.js';

// Writes the site data contract (plan 13 §4) onto the connection — the manifest universal-backend
// enforces at the generated-site runtime (auth-table pinning, write allow-lists, owner-scoped
// reads). Same owner-consent channel as set-public-permissions: agents-core has already collected
// the explicit user approval; this side re-checks that the approving user actually holds Cedar
// connection:edit and logs refusals. Unlike the public grant there is no merge mode — the agent
// derives the complete manifest from the schema it created and it replaces the stored one
// wholesale (a backfilled presence-only marker included).
@Injectable({ scope: Scope.REQUEST })
export class SetSiteRuntimePolicyUseCase
extends AbstractUseCase<SetSiteRuntimePolicyDs, SiteRuntimePolicyRO>
implements ISetSiteRuntimePolicy
{
private readonly logger = new Logger(SetSiteRuntimePolicyUseCase.name);

constructor(
@Inject(BaseType.GLOBAL_DB_CONTEXT)
protected _dbContext: IGlobalDatabaseContext,
private readonly cedarAuthService: CedarAuthorizationService,
) {
super();
}

protected async implementation(inputData: SetSiteRuntimePolicyDs): Promise<SiteRuntimePolicyRO> {
const { userId, connectionId, policy } = inputData;

// The DTO already requires an object; arrays satisfy @IsObject, so rule them out here —
// a jsonb array would make universal-backend's parser treat every section as absent.
if (typeof policy !== 'object' || policy === null || Array.isArray(policy)) {
throw new HttpException({ message: Messages.SITE_RUNTIME_POLICY_INVALID }, HttpStatus.BAD_REQUEST);
}

const allowed = await this.cedarAuthService.validate({
userId,
action: CedarAction.ConnectionEdit,
connectionId,
});
if (!allowed) {
// Audit trail: the approving user does not hold connection:edit, so the agent-collected
// approval is not honored (mirror of the set-public-permissions refusal).
this.logger.warn(
`Site-runtime-policy write REFUSED (no connection:edit): connection=${connectionId} user=${userId}`,
);
throw new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS);
}

await this._dbContext.connectionRepository.updateConnectionSiteRuntimePolicy(connectionId, policy);
// Audit trail for every write: this manifest decides what site visitors can do at runtime.
const authTable = (policy.auth as Record<string, unknown> | undefined)?.tableName ?? null;
const writeTables = Array.isArray(policy.write)
? policy.write.map((entry: { table?: string }) => entry?.table).filter(Boolean)
: [];
this.logger.log(
`Site-runtime-policy written: connection=${connectionId} user=${userId} ` +
`authTable=${authTable} writeTables=[${writeTables.join(', ')}]`,
);
return { policy };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ export class SitenovaConnectionForSiteRuntimeRO {
'Lets the caller sign/verify the same tokens SitenovaEndUserAuthService does.',
})
endUserJwtKey: string;

@ApiPropertyOptional({
type: 'object',
additionalProperties: true,
nullable: true,
description:
'Server-side site data contract (plan 13 §4). Null unless this connection backs a generated ' +
'site. universal-backend refuses connections with no contract when SITE_CONTRACT_REQUIRED is on ' +
'(Step 2a allow-list); Step 2b consumes its auth/write/owned-read rules.',
})
siteRuntimePolicy?: Record<string, unknown> | null;
}

export class SitenovaPublicReadValidationRO {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@
// keeping it cryptographically and logically separate from RocketAdmin platform user tokens.
export const SITENOVA_ENDUSER_AUDIENCE = 'sitenova:enduser';

// Token lifetime for generated-site visitors.
export const SITENOVA_ENDUSER_TOKEN_TTL = '7d';
// Token lifetime for generated-site visitors. Mirrors universal-backend's ENDUSER_TOKEN_TTL
// (plan 13 Step 1): shortened from 7d because these tokens have no server-side revocation yet, and
// kept identical across both services so a token issued by either has the same lifetime during
// cutover. Read once at module load from the shared env var; unset defaults to 24h.
export const SITENOVA_ENDUSER_TOKEN_TTL =
process.env.ENDUSER_TOKEN_TTL && process.env.ENDUSER_TOKEN_TTL.length > 0 ? process.env.ENDUSER_TOKEN_TTL : '24h';
Comment on lines +9 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 3 'ENDUSER_TOKEN_TTL|SITENOVA_ENDUSER_TOKEN_TTL' backend
rg -n -C 3 'validationSchema|validate\s*:|ConfigModule' backend

Repository: rocket-admin/rocketadmin

Length of output: 7274


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect relevant usages and package metadata without running repository code.
printf '\n--- data-structure excerpt ---\n'
sed -n '1,120p' backend/src/microservices/sitenova-microservice/data-structures/sitenova-site.ds.ts

printf '\n--- auth service excerpt ---\n'
sed -n '1,140p' backend/src/microservices/sitenova-microservice/services/sitenova-enduser-auth.service.ts

printf '\n--- jwt dependency metadata ---\n'
python3 - <<'PY'
import json, pathlib
paths = [path for path in pathlib.Path('backend').rglob('package-lock.json') | list(pathlib.Path('.').rglob('package-lock.json'))]
for p in sorted(paths)[:10]:
    print(f'\n[{p}]')
    try:
        data=json.loads(p.read_text())
    except Exception as e:
        print(e)
        continue
    for key,node in (data.get('packages') or {}).items():
        if key.endswith('/jsonwebtoken') or key == 'jsonwebtoken':
            print(node.get('version'), node.get('resolved'))
PY

printf '\n--- docs/package references ---\n'
cat package.json 2>/dev/null | sed -n '1,160p' || true
cat backend/package.json 2>/dev/null | sed -n '1,160p' || true

printf '\n--- static verification of assigned value and usage ---\n'
python3 - <<'PY'
from pathlib import Path
dt=Path('backend/src/microservices/sitenova-microservice/data-structures/sitenova-site.ds.ts')
au=Path('backend/src/microservices/sitenova-microservice/services/sitenova-enduser-auth.service.ts')
for p in (dt, au):
    text=p.read_text(encoding='utf-8')
    print(p, len(text.splitlines()))
PY

printf '\n--- package-lock jsonwebtoken entries ---\n'
python3 - <<'PY'
import json, pathlib
for p in pathlib.Path('.').rglob('package-lock.json'):
    if '.npm' in str(p):
        continue
    data=json.loads(p.read_text())
    for key,node in (data.get('packages') or {}).items():
        if key == 'backend' or key.endswith('/jsonwebtoken') or key == 'jsonwebtoken':
            print(key, node.get('name'), node.get('version'), node.get('resolved'))
PY

Repository: rocket-admin/rocketadmin

Length of output: 7755


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- package-lock jsonwebtoken entries ---\n'
python3 - <<'PY'
import json
from pathlib import Path
for p in Path('.').rglob('package-lock.json'):
    if '.npm' in str(p):
        continue
    try:
        data=json.loads(p.read_text())
    except Exception as e:
        print(f'[{p}] parse error {e}')
        continue
    for key,node in (data.get('packages') or {}).items():
        pkg_name=node.get('name') if isinstance(node, dict) else None
        if key.endswith('/jsonwebtoken') or key == 'jsonwebtoken' or pkg_name == 'jsonwebtoken':
            print(key, pkg_name, node.get('version'), node.get('resolved'))
    print()
PY

printf '\n--- root package.json ---\n'
cat package.json 2>/dev/null || true

printf '\n--- backend package.json ---\n'
cat backend/package.json 2>/dev/null | sed -n '1,220p' || true

printf '\n--- config module references ---\n'
cat backend/src/shared/config/config.module.ts 2>/dev/null | sed -n '1,220p' || true

Repository: rocket-admin/rocketadmin

Length of output: 6331


🌐 Web query:

jsonwebtoken v9 expiresIn accepts number milliseconds seconds valid duration string

💡 Result:

In jsonwebtoken v9, the expiresIn option is interpreted differently depending on whether you provide a number or a string [1][2]. When you provide a number, it is interpreted as the count in seconds (e.g., 60 means 60 seconds) [1][3]. When you provide a string, it must be a valid time span string (e.g., "2 days", "10h", "7d") [1][2]. If the string does not specify a unit, it is interpreted as milliseconds by default (e.g., "120" is treated as "120ms") [1][3]. To avoid confusion when using strings, it is recommended to explicitly include the time units (such as "s", "m", "h", "d") [1].

Citations:


Validate ENDUSER_TOKEN_TTL at config load.

SITENOVA_ENDUSER_TOKEN_TTL accepts any non-empty string and passes it to jwt.sign({ expiresIn: ... }). A bare value such as 120 is read as milliseconds, and an invalid duration can make SignUser login or request token signing blow up. Validate the duration up front and fail startup instead of deferring the failure to JWT creation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/src/microservices/sitenova-microservice/data-structures/sitenova-site.ds.ts`
around lines 9 - 10, Validate ENDUSER_TOKEN_TTL when initializing
SITENOVA_ENDUSER_TOKEN_TTL, accepting only durations supported by jwt.sign and
rejecting bare numeric or invalid values. Fail configuration loading immediately
with a clear error instead of allowing invalid TTLs to reach token-signing code.


// JWT payload issued to a generated-site end-user on register/login.
export interface SitenovaEndUserTokenPayload {
sub: string; // the user's identifier in the connection's users table (email by default)
uid?: string; // the user's row primary key — basis for row-level authorization (plan 13); optional during cutover
cid: string; // connectionId the token is bound to
aud: string; // SITENOVA_ENDUSER_AUDIENCE
}
Expand Down
Loading
Loading