diff --git a/packages/api-auth-entity-storage-models/src/index.ts b/packages/api-auth-entity-storage-models/src/index.ts index 4e984d2..974ce6e 100644 --- a/packages/api-auth-entity-storage-models/src/index.ts +++ b/packages/api-auth-entity-storage-models/src/index.ts @@ -1,4 +1,5 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. export * from "./models/api/IAdminUserCreateRequest.js"; export * from "./models/api/IAdminUserGetByIdentityRequest.js"; @@ -17,5 +18,6 @@ export * from "./models/api/IUpdatePasswordRequest.js"; export * from "./models/IAuthenticationAdminComponent.js"; export * from "./models/IAuthenticationComponent.js"; export * from "./models/IAuthenticationUser.js"; +export * from "./models/IAuthenticationUserCreate.js"; export * from "./models/IAuthenticationUserSecure.js"; export * from "./models/IPasswordOptions.js"; diff --git a/packages/api-auth-entity-storage-models/src/models/IAuthenticationAdminComponent.ts b/packages/api-auth-entity-storage-models/src/models/IAuthenticationAdminComponent.ts index 004e3e1..1551797 100644 --- a/packages/api-auth-entity-storage-models/src/models/IAuthenticationAdminComponent.ts +++ b/packages/api-auth-entity-storage-models/src/models/IAuthenticationAdminComponent.ts @@ -1,7 +1,9 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import type { IComponent } from "@3sixty/core"; import type { IAuthenticationUser } from "./IAuthenticationUser.js"; +import type { IAuthenticationUserCreate } from "./IAuthenticationUserCreate.js"; /** * Contract definition for authentication admin component. @@ -12,7 +14,7 @@ export interface IAuthenticationAdminComponent extends IComponent { * @param user The user to create. * @returns A promise that resolves when the user account has been created. */ - create(user: IAuthenticationUser & { password: string }): Promise; + create(user: IAuthenticationUserCreate): Promise; /** * Update a login for the user. diff --git a/packages/api-auth-entity-storage-models/src/models/IAuthenticationComponent.ts b/packages/api-auth-entity-storage-models/src/models/IAuthenticationComponent.ts index 7b1c372..e715a69 100644 --- a/packages/api-auth-entity-storage-models/src/models/IAuthenticationComponent.ts +++ b/packages/api-auth-entity-storage-models/src/models/IAuthenticationComponent.ts @@ -1,4 +1,5 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import type { IComponent } from "@3sixty/core"; import type { IPasswordOptions } from "./IPasswordOptions.js"; @@ -21,6 +22,12 @@ export interface IAuthenticationComponent extends IComponent { expiry: number; }>; + /** + * Attach a DID to the signed-in account. The token subject stays the account id. + * @returns A promise that resolves when the DID has been stored. + */ + adoptIdentity(): Promise; + /** * Logout the current user. * @param token The token to logout, if it uses a mechanism with public access. diff --git a/packages/api-auth-entity-storage-models/src/models/IAuthenticationUser.ts b/packages/api-auth-entity-storage-models/src/models/IAuthenticationUser.ts index a8b8e2c..9f3ef9c 100644 --- a/packages/api-auth-entity-storage-models/src/models/IAuthenticationUser.ts +++ b/packages/api-auth-entity-storage-models/src/models/IAuthenticationUser.ts @@ -1,4 +1,5 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. /** @@ -11,9 +12,14 @@ export interface IAuthenticationUser { email: string; /** - * The user identity. + * Stable identifier of the account. This is the token subject. */ - userIdentity: string; + userId: string; + + /** + * The DID attached to the account, once one has been adopted. + */ + userIdentity?: string; /** * The users organization. diff --git a/packages/api-auth-entity-storage-models/src/models/IAuthenticationUserCreate.ts b/packages/api-auth-entity-storage-models/src/models/IAuthenticationUserCreate.ts new file mode 100644 index 0000000..b5e501d --- /dev/null +++ b/packages/api-auth-entity-storage-models/src/models/IAuthenticationUserCreate.ts @@ -0,0 +1,28 @@ +// Copyright 2026 3Sixty Trade Ltd. +// SPDX-License-Identifier: Apache-2.0. + +/** + * Fields accepted when an admin creates a user. + * The DID and the organisation may be omitted. The service allocates the account id and fills the organisation. + */ +export interface IAuthenticationUserCreate { + /** + * The user e-mail address. + */ + email: string; + + /** + * Password for the new account. + */ + password: string; + + /** + * DID to store on the account. When omitted, the account is created without one. + */ + userIdentity?: string; + + /** + * Organisation identity. When omitted, the service uses the organisation on the request context. + */ + organizationIdentity?: string; +} diff --git a/packages/api-auth-entity-storage-models/src/models/api/IAdminUserCreateRequest.ts b/packages/api-auth-entity-storage-models/src/models/api/IAdminUserCreateRequest.ts index a2dd314..1a3c56a 100644 --- a/packages/api-auth-entity-storage-models/src/models/api/IAdminUserCreateRequest.ts +++ b/packages/api-auth-entity-storage-models/src/models/api/IAdminUserCreateRequest.ts @@ -1,6 +1,7 @@ // Copyright 2026 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. -import type { IAuthenticationUser } from "../IAuthenticationUser.js"; +import type { IAuthenticationUserCreate } from "../IAuthenticationUserCreate.js"; /** * Create a new user as an admin. @@ -9,5 +10,5 @@ export interface IAdminUserCreateRequest { /** * The body of the request. */ - body: IAuthenticationUser & { password: string }; + body: IAuthenticationUserCreate; } diff --git a/packages/api-auth-entity-storage-rest-client/src/entityStorageAuthenticationAdminRestClient.ts b/packages/api-auth-entity-storage-rest-client/src/entityStorageAuthenticationAdminRestClient.ts index fa2734d..3217035 100644 --- a/packages/api-auth-entity-storage-rest-client/src/entityStorageAuthenticationAdminRestClient.ts +++ b/packages/api-auth-entity-storage-rest-client/src/entityStorageAuthenticationAdminRestClient.ts @@ -1,4 +1,5 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import type { IAdminUserCreateRequest, @@ -9,7 +10,8 @@ import type { IAdminUserUpdatePasswordRequest, IAdminUserUpdateRequest, IAuthenticationAdminComponent, - IAuthenticationUser + IAuthenticationUser, + IAuthenticationUserCreate } from "@3sixty/api-auth-entity-storage-models"; import { BaseRestClient } from "@3sixty/api-core"; import type { IBaseRestClientConfig, INoContentResponse } from "@3sixty/api-models"; @@ -50,7 +52,7 @@ export class EntityStorageAuthenticationAdminRestClient * @param user The user to create. * @returns Nothing. */ - public async create(user: IAuthenticationUser & { password: string }): Promise { + public async create(user: IAuthenticationUserCreate): Promise { Guards.object(EntityStorageAuthenticationAdminRestClient.CLASS_NAME, nameof(user), user); Guards.stringValue( EntityStorageAuthenticationAdminRestClient.CLASS_NAME, diff --git a/packages/api-auth-entity-storage-rest-client/src/entityStorageAuthenticationRestClient.ts b/packages/api-auth-entity-storage-rest-client/src/entityStorageAuthenticationRestClient.ts index 2827da2..6d83683 100644 --- a/packages/api-auth-entity-storage-rest-client/src/entityStorageAuthenticationRestClient.ts +++ b/packages/api-auth-entity-storage-rest-client/src/entityStorageAuthenticationRestClient.ts @@ -1,4 +1,5 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import type { IAuthenticationComponent, @@ -96,6 +97,14 @@ export class EntityStorageAuthenticationRestClient }; } + /** + * Attach a DID to the signed-in account. The current session is left in place. + * @returns A promise that resolves when the request has completed. + */ + public async adoptIdentity(): Promise { + await this.fetch("/identity", "POST"); + } + /** * Logout the current user. * @param token The token to logout, if it uses a mechanism with public access. diff --git a/packages/api-auth-entity-storage-rest-client/tests/entityStorageAuthenticationAdminRestClient.spec.ts b/packages/api-auth-entity-storage-rest-client/tests/entityStorageAuthenticationAdminRestClient.spec.ts index 41a134f..2cca448 100644 --- a/packages/api-auth-entity-storage-rest-client/tests/entityStorageAuthenticationAdminRestClient.spec.ts +++ b/packages/api-auth-entity-storage-rest-client/tests/entityStorageAuthenticationAdminRestClient.spec.ts @@ -1,4 +1,5 @@ // Copyright 2026 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import { GuardError } from "@3sixty/core"; import { HttpMethod } from "@3sixty/web"; @@ -71,6 +72,20 @@ describe("EntityStorageAuthenticationAdminRestClient", () => { expect(JSON.parse(options.body)).toEqual(createUser); }); + test("sends a create body without a DID when userIdentity is omitted", async () => { + fetchMock.mockResolvedValueOnce(noContentResponse()); + + const { userIdentity, ...withoutDid } = TEST_USER; + await client.create({ ...withoutDid, password: "MyPassword123!" }); + + const [, options] = fetchMock.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.email).toBe(TEST_USER.email); + expect(body.password).toBe("MyPassword123!"); + expect(body.userIdentity).toBeUndefined(); + expect(userIdentity).toBe(TEST_USER.userIdentity); + }); + test("resolves without a return value", async () => { fetchMock.mockResolvedValueOnce(noContentResponse()); await expect( diff --git a/packages/api-auth-entity-storage-rest-client/tests/entityStorageAuthenticationRestClient.spec.ts b/packages/api-auth-entity-storage-rest-client/tests/entityStorageAuthenticationRestClient.spec.ts index 630fd1e..27d99d4 100644 --- a/packages/api-auth-entity-storage-rest-client/tests/entityStorageAuthenticationRestClient.spec.ts +++ b/packages/api-auth-entity-storage-rest-client/tests/entityStorageAuthenticationRestClient.spec.ts @@ -1,4 +1,5 @@ // Copyright 2026 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import { GuardError } from "@3sixty/core"; import { HttpMethod } from "@3sixty/web"; @@ -154,6 +155,25 @@ describe("EntityStorageAuthenticationRestClient", () => { }); }); + describe("adoptIdentity", () => { + test("sends POST to /identity with no body", async () => { + fetchMock.mockResolvedValueOnce(noContentResponse()); + + await client.adoptIdentity(); + + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe(`${ENDPOINT}/${PREFIX}/identity`); + expect(options.method).toBe(HttpMethod.POST); + expect(options.body).toBeUndefined(); + }); + + test("resolves without a return value", async () => { + fetchMock.mockResolvedValueOnce(noContentResponse()); + + await expect(client.adoptIdentity()).resolves.toBeUndefined(); + }); + }); + describe("updatePassword", () => { test("throws when currentPassword is empty", async () => { await expect(client.updatePassword("", "MyNewPassword123!")).rejects.toMatchObject({ diff --git a/packages/api-auth-entity-storage-service/locales/en.json b/packages/api-auth-entity-storage-service/locales/en.json index bd86eb0..f70c6b4 100644 --- a/packages/api-auth-entity-storage-service/locales/en.json +++ b/packages/api-auth-entity-storage-service/locales/en.json @@ -3,11 +3,15 @@ "entityStorageAuthenticationService": { "loginFailed": "Login failed", "userNotFound": "The user with the specified e-mail could not be found", + "identityAlreadyAssigned": "The user already has a DID", + "identityComponentMissing": "The identity component is not available", + "identityCreateFailed": "Creating the DID failed", "passwordMismatch": "The password does not match the user's password", "nodeIdNotSet": "The node ID has not been set" }, "entityStorageAuthenticationAdminService": { "userExists": "The user with the specified e-mail already exists", + "identityInUse": "An account already uses that identity", "createUserFailed": "Creating the user failed", "getUserFailed": "Getting the user failed", "updateUserFailed": "Updating the user failed", diff --git a/packages/api-auth-entity-storage-service/package.json b/packages/api-auth-entity-storage-service/package.json index 3b37e39..2bdec31 100644 --- a/packages/api-auth-entity-storage-service/package.json +++ b/packages/api-auth-entity-storage-service/package.json @@ -40,6 +40,7 @@ "@3sixty/crypto": "next", "@3sixty/entity": "next", "@3sixty/entity-storage-models": "next", + "@3sixty/identity-models": "next", "@3sixty/nameof": "next", "@3sixty/vault-models": "next", "@3sixty/web": "next" diff --git a/packages/api-auth-entity-storage-service/src/entities/authenticationUser.ts b/packages/api-auth-entity-storage-service/src/entities/authenticationUser.ts index 20abb49..b50f20c 100644 --- a/packages/api-auth-entity-storage-service/src/entities/authenticationUser.ts +++ b/packages/api-auth-entity-storage-service/src/entities/authenticationUser.ts @@ -1,11 +1,12 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import { entity, property } from "@3sixty/entity"; /** * Class defining the storage for user login credentials. */ -@entity({ version: 1 }) +@entity({ version: 2 }) export class AuthenticationUser { /** * The user e-mail address. @@ -26,10 +27,16 @@ export class AuthenticationUser { public salt!: string; /** - * The user identity. + * Stable identifier of the account. This is the token subject. */ @property({ type: "string", maxLength: 255, isSecondary: true }) - public identity!: string; + public userId!: string; + + /** + * The DID attached to the account, once one has been adopted. + */ + @property({ type: "string", maxLength: 255, isSecondary: true, optional: true }) + public identity?: string; /** * The users organization. diff --git a/packages/api-auth-entity-storage-service/src/entities/authenticationUserV1.ts b/packages/api-auth-entity-storage-service/src/entities/authenticationUserV1.ts new file mode 100644 index 0000000..0f2191f --- /dev/null +++ b/packages/api-auth-entity-storage-service/src/entities/authenticationUserV1.ts @@ -0,0 +1,46 @@ +// Copyright 2026 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. +// SPDX-License-Identifier: Apache-2.0. +import { entity, property } from "@3sixty/entity"; + +/** + * Account storage after authorization removed the stored scope, and before each account had its own id. + */ +@entity({ version: 1 }) +export class AuthenticationUserV1 { + /** + * The user e-mail address. + */ + @property({ type: "string", format: "email", isPrimary: true }) + public email!: string; + + /** + * The encrypted password for the user. + */ + @property({ type: "string" }) + public password!: string; + + /** + * The salt for the password. + */ + @property({ type: "string" }) + public salt!: string; + + /** + * The user identity. + */ + @property({ type: "string", maxLength: 255, isSecondary: true }) + public identity!: string; + + /** + * The users organization. + */ + @property({ type: "string", maxLength: 255 }) + public organization!: string; + + /** + * The password version counter, incremented on every password change to invalidate existing tokens. + */ + @property({ type: "integer", optional: true }) + public passwordVersion?: number; +} diff --git a/packages/api-auth-entity-storage-service/src/index.ts b/packages/api-auth-entity-storage-service/src/index.ts index 10132b6..b9cc9fc 100644 --- a/packages/api-auth-entity-storage-service/src/index.ts +++ b/packages/api-auth-entity-storage-service/src/index.ts @@ -1,7 +1,9 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. export * from "./entities/authenticationUser.js"; export * from "./entities/authenticationUserV0.js"; +export * from "./entities/authenticationUserV1.js"; export * from "./models/IAuthHeaderProcessorConfig.js"; export * from "./models/IAuthHeaderProcessorConstructorOptions.js"; export * from "./models/IAuthTokenContext.js"; diff --git a/packages/api-auth-entity-storage-service/src/models/IAuthTokenContext.ts b/packages/api-auth-entity-storage-service/src/models/IAuthTokenContext.ts index 013521f..97fe913 100644 --- a/packages/api-auth-entity-storage-service/src/models/IAuthTokenContext.ts +++ b/packages/api-auth-entity-storage-service/src/models/IAuthTokenContext.ts @@ -1,4 +1,5 @@ // Copyright 2026 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. /** @@ -22,10 +23,15 @@ export interface IAuthTokenContext { tenantPublicOrigin?: string; /** - * The identity of the verified user. + * The account id of the verified user. This is the token subject. */ userIdentity?: string; + /** + * The stored identity of the verified account, when it has one. + */ + identity?: string; + /** * The organization of the verified user. */ diff --git a/packages/api-auth-entity-storage-service/src/models/IEntityStorageAuthenticationServiceConfig.ts b/packages/api-auth-entity-storage-service/src/models/IEntityStorageAuthenticationServiceConfig.ts index 70f864c..b5a021a 100644 --- a/packages/api-auth-entity-storage-service/src/models/IEntityStorageAuthenticationServiceConfig.ts +++ b/packages/api-auth-entity-storage-service/src/models/IEntityStorageAuthenticationServiceConfig.ts @@ -1,4 +1,5 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import type { IAuthenticationRateActionConfig } from "@3sixty/api-auth-models"; @@ -47,4 +48,10 @@ export interface IEntityStorageAuthenticationServiceConfig { * @default { maxAttempts: 30, windowMinutes: 60 } */ tokenRefreshRateLimit?: IAuthenticationRateActionConfig; + + /** + * Optional override for identity adoption rate limit. + * @default { maxAttempts: 5, windowMinutes: 15 } + */ + identityAdoptRateLimit?: IAuthenticationRateActionConfig; } diff --git a/packages/api-auth-entity-storage-service/src/models/IEntityStorageAuthenticationServiceConstructorOptions.ts b/packages/api-auth-entity-storage-service/src/models/IEntityStorageAuthenticationServiceConstructorOptions.ts index fd62e8c..2e5e27d 100644 --- a/packages/api-auth-entity-storage-service/src/models/IEntityStorageAuthenticationServiceConstructorOptions.ts +++ b/packages/api-auth-entity-storage-service/src/models/IEntityStorageAuthenticationServiceConstructorOptions.ts @@ -1,4 +1,5 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import type { IEntityStorageAuthenticationServiceConfig } from "./IEntityStorageAuthenticationServiceConfig.js"; @@ -36,6 +37,12 @@ export interface IEntityStorageAuthenticationServiceConstructorOptions { */ tenantAdminComponentType?: string; + /** + * The identity component used to create a DID. + * @default identity-service + */ + identityComponentType?: string; + /** * The configuration for the authentication. */ diff --git a/packages/api-auth-entity-storage-service/src/processors/authHeaderProcessor.ts b/packages/api-auth-entity-storage-service/src/processors/authHeaderProcessor.ts index fd9b21c..f2388db 100644 --- a/packages/api-auth-entity-storage-service/src/processors/authHeaderProcessor.ts +++ b/packages/api-auth-entity-storage-service/src/processors/authHeaderProcessor.ts @@ -1,4 +1,5 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import { HttpContextIdKeys, @@ -207,7 +208,16 @@ export class AuthHeaderProcessor implements IBaseRouteProcessor { // in a single-tenant environment we fall back to the node organization ID. contextIds[ContextIdKeys.Organization] = tokenContext.tenantOrganizationId ?? this._nodeOrganizationId; - contextIds[ContextIdKeys.User] = tokenContext.userIdentity; + if (Is.stringValue(tokenContext.userIdentity)) { + contextIds[ContextIdKeys.Account] = tokenContext.userIdentity; + } else { + delete contextIds[ContextIdKeys.Account]; + } + if (Is.stringValue(tokenContext.identity) && tokenContext.identity.startsWith("did:")) { + contextIds[ContextIdKeys.User] = tokenContext.identity; + } else { + delete contextIds[ContextIdKeys.User]; + } contextIds[ContextIdKeys.UserOrganization] = tokenContext.userOrganization; // If the tenant has a custom public origin, we set it in the context for downstream processors to use. @@ -367,10 +377,10 @@ export class AuthHeaderProcessor implements IBaseRouteProcessor { // Wrap the user lookup in the request context so partitioned storage uses the correct tenant. user = await ContextIdStore.run(contextIdsForUserLookup, async () => - this._userEntityStorage.get(sub, "identity") + this._userEntityStorage.get(sub, "userId") ); - if (user?.identity === sub && (passwordVersion ?? 0) === (user.passwordVersion ?? 0)) { + if (user?.userId === sub && (passwordVersion ?? 0) === (user.passwordVersion ?? 0)) { validParts.push("user"); } if (user?.organization === org) { @@ -387,7 +397,8 @@ export class AuthHeaderProcessor implements IBaseRouteProcessor { tenantId, tenantOrganizationId, tenantPublicOrigin, - userIdentity: user?.identity, + userIdentity: user?.userId, + identity: Is.stringValue(user?.identity) ? user.identity : undefined, userOrganization: user?.organization, expires: Is.integer(expiresSeconds) ? expiresSeconds * 1000 : undefined }; diff --git a/packages/api-auth-entity-storage-service/src/routes/entityStorageAuthenticationAdminRoutes.ts b/packages/api-auth-entity-storage-service/src/routes/entityStorageAuthenticationAdminRoutes.ts index fe0837a..45f60a9 100644 --- a/packages/api-auth-entity-storage-service/src/routes/entityStorageAuthenticationAdminRoutes.ts +++ b/packages/api-auth-entity-storage-service/src/routes/entityStorageAuthenticationAdminRoutes.ts @@ -1,4 +1,5 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import type { IAdminUserCreateRequest, @@ -86,6 +87,17 @@ export function generateRestRoutesAuthenticationAdmin( organizationIdentity: "did:example:123456789abcdefghi" } } + }, + { + id: "createUserWithoutIdentityRequestExample", + description: + "The request to create a user before a DID exists. The service allocates an account id and stores the context organisation as the organisation.", + request: { + body: { + email: "user@example.com", + password: "MyPassword123!" + } + } } ] }, @@ -205,6 +217,7 @@ export function generateRestRoutesAuthenticationAdmin( response: { body: { email: "user@example.com", + userId: "6f1c2c0e-4b7a-4f0e-9c1a-2d3e4f5a6b7c", userIdentity: "did:example:123456789abcdefghi", organizationIdentity: "did:example:123456789abcdefghi" } @@ -252,6 +265,7 @@ export function generateRestRoutesAuthenticationAdmin( response: { body: { email: "user@example.com", + userId: "6f1c2c0e-4b7a-4f0e-9c1a-2d3e4f5a6b7c", userIdentity: "did:example:123456789abcdefghi", organizationIdentity: "did:example:123456789abcdefghi" } diff --git a/packages/api-auth-entity-storage-service/src/routes/entityStorageAuthenticationRoutes.ts b/packages/api-auth-entity-storage-service/src/routes/entityStorageAuthenticationRoutes.ts index 85eedde..c9c9555 100644 --- a/packages/api-auth-entity-storage-service/src/routes/entityStorageAuthenticationRoutes.ts +++ b/packages/api-auth-entity-storage-service/src/routes/entityStorageAuthenticationRoutes.ts @@ -1,4 +1,5 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import type { IAuthenticationComponent, @@ -11,6 +12,7 @@ import type { IUpdatePasswordRequest } from "@3sixty/api-auth-entity-storage-models"; import type { + IConflictResponse, IHttpRequestContext, INoContentRequest, INoContentResponse, @@ -195,7 +197,30 @@ export function generateRestRoutesAuthentication( { type: nameof() } - ] + ], + requiresAuthorization: false + }; + + const adoptIdentityRoute: IRestRoute = { + operationId: "authenticationAdoptIdentity", + summary: "Adopt a DID for the signed-in user", + tag: tagsAuthentication[0].name, + method: "POST", + path: `${baseRouteName}/identity`, + handler: async httpRequestContext => + authenticationAdoptIdentity(httpRequestContext, componentName), + responseType: [ + { + type: nameof() + }, + { + type: nameof() + }, + { + type: nameof() + } + ], + requiresAuthorization: false }; const passwordPolicyRoute: IRestRoute = { @@ -230,7 +255,14 @@ export function generateRestRoutesAuthentication( skipTenant: true }; - return [loginRoute, logoutRoute, refreshTokenRoute, updatePasswordRoute, passwordPolicyRoute]; + return [ + loginRoute, + logoutRoute, + refreshTokenRoute, + updatePasswordRoute, + adoptIdentityRoute, + passwordPolicyRoute + ]; } /** @@ -263,6 +295,24 @@ export async function authenticationLogin( }; } +/** + * Attach a DID to the signed-in account. The current session is left in place. + * @param httpRequestContext The request context for the API. + * @param componentName The name of the component to use in the routes. + * @returns An empty response once the DID has been stored. + */ +export async function authenticationAdoptIdentity( + httpRequestContext: IHttpRequestContext, + componentName: string +): Promise { + const component = ComponentFactory.get(componentName); + await component.adoptIdentity(); + + return { + statusCode: HttpStatusCode.noContent + }; +} + /** * Logout from the server. * @param httpRequestContext The request context for the API. diff --git a/packages/api-auth-entity-storage-service/src/schema.ts b/packages/api-auth-entity-storage-service/src/schema.ts index 3fe63a2..972e565 100644 --- a/packages/api-auth-entity-storage-service/src/schema.ts +++ b/packages/api-auth-entity-storage-service/src/schema.ts @@ -1,9 +1,13 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. +import { RandomHelper } from "@3sixty/core"; import { EntitySchemaFactory, EntitySchemaHelper } from "@3sixty/entity"; +import { SchemaMigrationFactory, type ISchemaMigration } from "@3sixty/entity-storage-models"; import { nameof } from "@3sixty/nameof"; import { AuthenticationUser } from "./entities/authenticationUser.js"; import { AuthenticationUserV0 } from "./entities/authenticationUserV0.js"; +import { AuthenticationUserV1 } from "./entities/authenticationUserV1.js"; /** * Initialize the schema for the authentication service. @@ -15,4 +19,19 @@ export function initSchema(): void { EntitySchemaFactory.register(nameof(), () => EntitySchemaHelper.getSchema(AuthenticationUserV0) ); + EntitySchemaFactory.register(nameof(), () => + EntitySchemaHelper.getSchema(AuthenticationUserV1) + ); + + // A stored account has an identity and no account id. The id is new, so the row cannot supply it. + const allocateAccountId: ISchemaMigration = { + transformEntity: entity => ({ + ...entity, + userId: RandomHelper.generateUuidV7() + }) + }; + SchemaMigrationFactory.register( + `${nameof()}_1_2`, + () => allocateAccountId as ISchemaMigration + ); } diff --git a/packages/api-auth-entity-storage-service/src/services/entityStorageAuthenticationAdminService.ts b/packages/api-auth-entity-storage-service/src/services/entityStorageAuthenticationAdminService.ts index c7d5a0a..1ea423f 100644 --- a/packages/api-auth-entity-storage-service/src/services/entityStorageAuthenticationAdminService.ts +++ b/packages/api-auth-entity-storage-service/src/services/entityStorageAuthenticationAdminService.ts @@ -1,8 +1,10 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import type { IAuthenticationAdminComponent, - IAuthenticationUser + IAuthenticationUser, + IAuthenticationUserCreate } from "@3sixty/api-auth-entity-storage-models"; import { type IAuthenticationAuditComponent, AuthAuditEvent } from "@3sixty/api-auth-models"; import { ForbiddenError, HttpContextIdKeys } from "@3sixty/api-models"; @@ -116,6 +118,21 @@ export class EntityStorageAuthenticationAdminService implements IAuthenticationA EntityStorageAuthenticationAdminService.DEFAULT_AUTHORIZATION_MODEL_ID; } + /** + * Map a stored account to the admin response. + * @param user The stored account. + * @returns The account fields returned to callers. The DID is included only when one is stored. + * @internal + */ + private static toAuthenticationUser(user: AuthenticationUser): IAuthenticationUser { + return { + email: user.email, + userId: user.userId, + userIdentity: Is.stringValue(user.identity) ? user.identity : undefined, + organizationIdentity: user.organization + }; + } + /** * Returns the class name of the component. * @returns The class name of the component. @@ -126,11 +143,12 @@ export class EntityStorageAuthenticationAdminService implements IAuthenticationA /** * Create a login for the user. + * The service allocates the account id. A DID is stored only when one is provided. A missing organisation is taken from the request context. * @param user The user to create. * @returns A promise that resolves when the user account has been created and the audit entry recorded. */ - public async create(user: IAuthenticationUser & { password: string }): Promise { - Guards.object( + public async create(user: IAuthenticationUserCreate): Promise { + Guards.object( EntityStorageAuthenticationAdminService.CLASS_NAME, nameof(user), user @@ -145,17 +163,28 @@ export class EntityStorageAuthenticationAdminService implements IAuthenticationA nameof(user.password), user.password ); - Guards.stringValue( - EntityStorageAuthenticationAdminService.CLASS_NAME, - nameof(user.userIdentity), - user.userIdentity - ); + + let userIdentity: string | undefined; + if (user.userIdentity !== undefined) { + userIdentity = user.userIdentity.trim(); + Guards.stringValue( + EntityStorageAuthenticationAdminService.CLASS_NAME, + nameof(user.userIdentity), + userIdentity + ); + } + + const contextIds = await ContextIdStore.getContextIds(); + const organizationIdentity = + user.organizationIdentity ?? contextIds?.[ContextIdKeys.Organization] ?? ""; Guards.stringValue( EntityStorageAuthenticationAdminService.CLASS_NAME, nameof(user.organizationIdentity), - user.organizationIdentity + organizationIdentity.trim() ); + const userId = RandomHelper.generateUuidV7(); + try { PasswordValidator.validatePassword(user.password, { minLength: this._minPasswordLength, @@ -167,8 +196,17 @@ export class EntityStorageAuthenticationAdminService implements IAuthenticationA throw new GeneralError(EntityStorageAuthenticationAdminService.CLASS_NAME, "userExists"); } - // Roles belong to the identity, so a login mapped to an escalated identity inherits them. - await this.guardEscalatedPrivilegeTarget(user.userIdentity); + if (userIdentity !== undefined) { + // Roles belong to the identity, so a login mapped to an escalated identity inherits them. + await this.guardEscalatedPrivilegeTarget(userIdentity); + const existingIdentity = await this._userEntityStorage.get(userIdentity, "identity"); + if (Is.object(existingIdentity)) { + throw new GeneralError( + EntityStorageAuthenticationAdminService.CLASS_NAME, + "identityInUse" + ); + } + } const saltBytes = RandomHelper.generate(16); const passwordBytes = Converter.utf8ToBytes(user.password); @@ -179,22 +217,25 @@ export class EntityStorageAuthenticationAdminService implements IAuthenticationA email: user.email, salt: Converter.bytesToBase64(saltBytes), password: hashedPassword, - identity: user.userIdentity, - organization: user.organizationIdentity, + userId, + organization: organizationIdentity, passwordVersion: 0 }; + if (userIdentity !== undefined) { + newUser.identity = userIdentity; + } await this._userEntityStorage.set(newUser); - const contextIds = await ContextIdStore.getContextIds(); const requestorTenantId = contextIds?.[ContextIdKeys.Tenant]; const originalTenantId = contextIds?.[HttpContextIdKeys.OriginalTenant]; await this._authenticationAuditService?.create({ actorId: user.email, event: AuthAuditEvent.AccountCreated, data: { - userIdentity: user.userIdentity, - organizationIdentity: user.organizationIdentity, + userId, + userIdentity, + organizationIdentity, tenantId: requestorTenantId, originalTenantId } @@ -284,7 +325,8 @@ export class EntityStorageAuthenticationAdminService implements IAuthenticationA event: AuthAuditEvent.AccountUpdated, data: { updatedFields, - userIdentity: existingUser.identity, + userId: existingUser.userId, + userIdentity: Is.stringValue(existingUser.identity) ? existingUser.identity : undefined, organizationIdentity: existingUser.organization, tenantId: requestorTenantId, originalTenantId @@ -321,11 +363,7 @@ export class EntityStorageAuthenticationAdminService implements IAuthenticationA ); } - return { - email: user.email, - userIdentity: user.identity, - organizationIdentity: user.organization - }; + return EntityStorageAuthenticationAdminService.toAuthenticationUser(user); } catch (error) { throw new GeneralError( EntityStorageAuthenticationAdminService.CLASS_NAME, @@ -358,11 +396,7 @@ export class EntityStorageAuthenticationAdminService implements IAuthenticationA ); } - return { - email: user.email, - userIdentity: user.identity, - organizationIdentity: user.organization - }; + return EntityStorageAuthenticationAdminService.toAuthenticationUser(user); } catch (error) { throw new GeneralError( EntityStorageAuthenticationAdminService.CLASS_NAME, @@ -402,7 +436,8 @@ export class EntityStorageAuthenticationAdminService implements IAuthenticationA actorId: email, event: AuthAuditEvent.AccountDeleted, data: { - userIdentity: user.identity, + userId: user.userId, + userIdentity: Is.stringValue(user.identity) ? user.identity : undefined, organizationIdentity: user.organization, tenantId: requestorTenantId, originalTenantId @@ -478,12 +513,12 @@ export class EntityStorageAuthenticationAdminService implements IAuthenticationA /** * Ensure the caller holds escalated privilege when the target identity holds it. - * @param targetIdentity The identity of the account being modified. + * @param targetIdentity The identity of the account being modified. Accounts with no DID are skipped. * @throws ForbiddenError if the target holds escalated privilege and the caller does not. * @internal */ - private async guardEscalatedPrivilegeTarget(targetIdentity: string): Promise { - if (Is.empty(this._authorizationComponent)) { + private async guardEscalatedPrivilegeTarget(targetIdentity?: string): Promise { + if (!Is.stringValue(targetIdentity) || Is.empty(this._authorizationComponent)) { return; } diff --git a/packages/api-auth-entity-storage-service/src/services/entityStorageAuthenticationService.ts b/packages/api-auth-entity-storage-service/src/services/entityStorageAuthenticationService.ts index f4d5862..78e3e90 100644 --- a/packages/api-auth-entity-storage-service/src/services/entityStorageAuthenticationService.ts +++ b/packages/api-auth-entity-storage-service/src/services/entityStorageAuthenticationService.ts @@ -1,4 +1,5 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import type { IAuthenticationComponent, @@ -15,6 +16,7 @@ import { ContextIdHelper, ContextIdKeys, ContextIdStore } from "@3sixty/context" import { Coerce, ComponentFactory, + ConflictError, Converter, GeneralError, Guards, @@ -27,6 +29,7 @@ import { EntityStorageConnectorFactory, type IEntityStorageConnector } from "@3sixty/entity-storage-models"; +import type { IIdentityComponent } from "@3sixty/identity-models"; import { nameof } from "@3sixty/nameof"; import { VaultConnectorFactory, type IVaultConnector } from "@3sixty/vault-models"; import type { AuthenticationUser } from "../entities/authenticationUser.js"; @@ -85,6 +88,18 @@ export class EntityStorageAuthenticationService implements IAuthenticationCompon */ private static readonly _DEFAULT_TOKEN_REFRESH_RATE_WINDOW_MINUTES: number = 60; + /** + * Default maximum identity adoption attempts in a rate window. + * @internal + */ + private static readonly _DEFAULT_IDENTITY_ADOPT_RATE_MAX_ATTEMPTS: number = 5; + + /** + * Default identity adoption rate window in minutes. + * @internal + */ + private static readonly _DEFAULT_IDENTITY_ADOPT_RATE_WINDOW_MINUTES: number = 15; + /** * The audit service. * @internal @@ -151,12 +166,24 @@ export class EntityStorageAuthenticationService implements IAuthenticationCompon */ private readonly _tokenRefreshRateLimit: IAuthenticationRateActionConfig; + /** + * Rate limit configuration for identity adoption. + * @internal + */ + private readonly _identityAdoptRateLimit: IAuthenticationRateActionConfig; + /** * The component to retrieve tenant information. * @internal */ private readonly _tenantAdminComponent?: ITenantAdminComponent; + /** + * The identity component used to create a self-controlled DID. + * @internal + */ + private readonly _identityComponent?: IIdentityComponent; + /** * The node identity. * @internal @@ -186,6 +213,10 @@ export class EntityStorageAuthenticationService implements IAuthenticationCompon options?.tenantAdminComponentType ?? "tenant-admin" ); + this._identityComponent = ComponentFactory.getIfExists( + options?.identityComponentType ?? "identity-service" + ); + this._signingKeyName = options?.config?.signingKeyName ?? "auth-signing"; this._defaultTtlMinutes = options?.config?.defaultTtlMinutes ?? EntityStorageAuthenticationService._DEFAULT_TTL_MINUTES; @@ -215,6 +246,14 @@ export class EntityStorageAuthenticationService implements IAuthenticationCompon options?.config?.tokenRefreshRateLimit?.windowMinutes ?? EntityStorageAuthenticationService._DEFAULT_TOKEN_REFRESH_RATE_WINDOW_MINUTES }; + this._identityAdoptRateLimit = { + maxAttempts: + options?.config?.identityAdoptRateLimit?.maxAttempts ?? + EntityStorageAuthenticationService._DEFAULT_IDENTITY_ADOPT_RATE_MAX_ATTEMPTS, + windowMinutes: + options?.config?.identityAdoptRateLimit?.windowMinutes ?? + EntityStorageAuthenticationService._DEFAULT_IDENTITY_ADOPT_RATE_WINDOW_MINUTES + }; } /** @@ -244,6 +283,10 @@ export class EntityStorageAuthenticationService implements IAuthenticationCompon "token-refresh", this._tokenRefreshRateLimit ); + await this._authenticationRateService.registerAction( + "identity-adopt", + this._identityAdoptRateLimit + ); } /** @@ -255,6 +298,7 @@ export class EntityStorageAuthenticationService implements IAuthenticationCompon await this._authenticationRateService.unregisterAction("login"); await this._authenticationRateService.unregisterAction("password-change"); await this._authenticationRateService.unregisterAction("token-refresh"); + await this._authenticationRateService.unregisterAction("identity-adopt"); } /** @@ -308,7 +352,7 @@ export class EntityStorageAuthenticationService implements IAuthenticationCompon this._vaultConnector, this._nodeId, this._signingKeyName, - user.identity, + user.userId, user.organization, loginTenantId, this._defaultTtlMinutes, @@ -335,7 +379,8 @@ export class EntityStorageAuthenticationService implements IAuthenticationCompon actorId: email, event: AuthAuditEvent.LoginSuccess, data: { - userIdentity: loginUser.identity, + userId: loginUser.userId, + userIdentity: Is.stringValue(loginUser.identity) ? loginUser.identity : undefined, organizationIdentity: loginUser.organization, tenantId: loginTenantId } @@ -344,6 +389,81 @@ export class EntityStorageAuthenticationService implements IAuthenticationCompon return tokenAndExpiry; } + /** + * Attach a DID to the signed-in account. The token subject stays the account id. + * @returns A promise that resolves when the DID has been stored. + * @throws NotFoundError When the signed-in user cannot be loaded. + * @throws ConflictError When the user already has a DID. + * @throws GeneralError When the node or the identity component is not available, or the new document id is not a DID. + */ + public async adoptIdentity(): Promise { + if (!Is.stringValue(this._nodeId)) { + throw new GeneralError(EntityStorageAuthenticationService.CLASS_NAME, "nodeIdNotSet"); + } + + const contextIds = await ContextIdStore.getContextIds(); + ContextIdHelper.guard(contextIds, ContextIdKeys.Account); + const subject = contextIds[ContextIdKeys.Account]; + + const user = await this._userEntityStorage.get(subject, "userId"); + if (!Is.object(user) || user.userId !== subject) { + throw new NotFoundError( + EntityStorageAuthenticationService.CLASS_NAME, + "userNotFound", + subject + ); + } + + if (user.identity?.startsWith("did:")) { + throw new ConflictError( + EntityStorageAuthenticationService.CLASS_NAME, + "identityAlreadyAssigned" + ); + } + + await this._authenticationRateService.check("identity-adopt", subject); + + const identityComponent = this._identityComponent; + if (!Is.object(identityComponent)) { + throw new GeneralError( + EntityStorageAuthenticationService.CLASS_NAME, + "identityComponentMissing" + ); + } + + const created = await identityComponent.identityCreate(undefined, undefined, { + selfControlled: true + }); + const documentId = created.id; + if (!Is.stringValue(documentId) || !documentId.startsWith("did:")) { + throw new GeneralError(EntityStorageAuthenticationService.CLASS_NAME, "identityCreateFailed"); + } + + try { + await this._userEntityStorage.set({ + ...user, + identity: documentId + }); + } catch (error) { + await identityComponent.identityRemove( + documentId, + { removeKeys: true, removeDocumentKey: true }, + documentId + ); + throw error; + } + + await this._authenticationAuditService?.create({ + actorId: user.userId, + event: AuthAuditEvent.AccountUpdated, + data: { + previousIdentity: Is.stringValue(user.identity) ? user.identity : undefined, + userIdentity: documentId, + organizationIdentity: user.organization + } + }); + } + /** * Logout the current user. * @param token The token to logout, if it uses a mechanism with public access. @@ -353,7 +473,7 @@ export class EntityStorageAuthenticationService implements IAuthenticationCompon // Nothing to do here, as we are stateless. // The cookie will be revoked by the REST route handling const contextIds = await ContextIdStore.getContextIds(); - const identifier = contextIds?.[ContextIdKeys.User]; + const identifier = contextIds?.[ContextIdKeys.Account]; if (Is.stringValue(identifier)) { await this._authenticationAuditService?.create({ actorId: identifier, @@ -406,12 +526,12 @@ export class EntityStorageAuthenticationService implements IAuthenticationCompon // Wrap the user lookup in the request context so partitioned storage uses the correct tenant. const user = await ContextIdStore.run(contextIdsForUserLookup, async () => - this._userEntityStorage.get(sub, "identity") + this._userEntityStorage.get(sub, "userId") ); refreshUser = user; - if (user?.identity === sub && (passwordVersion ?? 0) === (user?.passwordVersion ?? 0)) { + if (user?.userId === sub && (passwordVersion ?? 0) === (user.passwordVersion ?? 0)) { validParts.push("user"); } if (user?.organization === org) { @@ -458,17 +578,17 @@ export class EntityStorageAuthenticationService implements IAuthenticationCompon */ public async updatePassword(currentPassword: string, newPassword: string): Promise { const contextIds = await ContextIdStore.getContextIds(); - ContextIdHelper.guard(contextIds, ContextIdKeys.User); + ContextIdHelper.guard(contextIds, ContextIdKeys.Account); - const userIdentity = contextIds[ContextIdKeys.User]; - await this._authenticationRateService.check("password-change", userIdentity); + const userId = contextIds[ContextIdKeys.Account]; + await this._authenticationRateService.check("password-change", userId); - const user = await this._userEntityStorage.get(userIdentity, "identity"); - if (!Is.object(user)) { + const user = await this._userEntityStorage.get(userId, "userId"); + if (!Is.object(user) || user.userId !== userId) { throw new NotFoundError( EntityStorageAuthenticationService.CLASS_NAME, "userNotFound", - userIdentity + userId ); } @@ -484,7 +604,7 @@ export class EntityStorageAuthenticationService implements IAuthenticationCompon } ); - await this._authenticationRateService.clear("password-change", userIdentity); + await this._authenticationRateService.clear("password-change", userId); } /** diff --git a/packages/api-auth-entity-storage-service/src/utils/passwordHelper.ts b/packages/api-auth-entity-storage-service/src/utils/passwordHelper.ts index 7ac52e0..fee6178 100644 --- a/packages/api-auth-entity-storage-service/src/utils/passwordHelper.ts +++ b/packages/api-auth-entity-storage-service/src/utils/passwordHelper.ts @@ -1,4 +1,5 @@ // Copyright 2024 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import type { IPasswordOptions } from "@3sixty/api-auth-entity-storage-models"; import type { IAuthenticationAuditComponent } from "@3sixty/api-auth-models"; @@ -61,17 +62,21 @@ export class PasswordHelper { email: user.email, salt: Converter.bytesToBase64(saltBytes), password: hashedPassword, - identity: user.identity, + userId: user.userId, organization: user.organization, passwordVersion: (user.passwordVersion ?? 0) + 1 }; + if (user.identity !== undefined) { + updatedUser.identity = user.identity; + } await userEntityStorage.set(updatedUser); await authenticationAuditService?.create({ actorId: user.email, event: AuthAuditEvent.PasswordChanged, data: { - userIdentity: updatedUser.identity, + userId: updatedUser.userId, + userIdentity: Is.stringValue(updatedUser.identity) ? updatedUser.identity : undefined, organizationIdentity: updatedUser.organization } }); diff --git a/packages/api-auth-entity-storage-service/tests/helpers/migrateAuthenticationUser.ts b/packages/api-auth-entity-storage-service/tests/helpers/migrateAuthenticationUser.ts new file mode 100644 index 0000000..ee237fc --- /dev/null +++ b/packages/api-auth-entity-storage-service/tests/helpers/migrateAuthenticationUser.ts @@ -0,0 +1,48 @@ +// Copyright 2026 3Sixty Trade Ltd. +// SPDX-License-Identifier: Apache-2.0. +import { EntitySchemaFactory, EntitySchemaHelper } from "@3sixty/entity"; +import { + MigrationHelper, + SchemaMigrationFactory, + type IResolvedMigrationStep +} from "@3sixty/entity-storage-models"; +import { nameof } from "@3sixty/nameof"; +import type { AuthenticationUser } from "../../src/entities/authenticationUser.js"; + +/** + * Run the stored account forward to the current schema, the same way the node does at start-up. + * @param entity The account as it was stored. + * @param fromVersion The version it was stored at. + * @returns The account at the current version. + */ +export async function migrateAuthenticationUser( + entity: unknown, + fromVersion: number +): Promise { + const currentVersion = EntitySchemaHelper.getVersion( + EntitySchemaFactory.get(nameof()) + ); + const steps: IResolvedMigrationStep[] = []; + + for (let version = fromVersion; version < currentVersion; version++) { + const fromSchema = EntitySchemaFactory.get(`${nameof()}V${version}`); + const toSchema = + version + 1 < currentVersion + ? EntitySchemaFactory.get(`${nameof()}V${version + 1}`) + : EntitySchemaFactory.get(nameof()); + const override = SchemaMigrationFactory.getIfExists( + `${nameof()}_${version}_${version + 1}` + ); + + steps.push({ + fromProperties: fromSchema.properties ?? [], + toProperties: toSchema.properties ?? [], + renames: override?.renames, + transformEntity: override?.transformEntity, + transformEntityProperty: override?.transformEntityProperty, + removeEntityProperty: override?.removeEntityProperty + }); + } + + return (await MigrationHelper.applyEntityChain(entity, steps)) as AuthenticationUser; +} diff --git a/packages/api-auth-entity-storage-service/tests/processors/authHeaderProcessor.spec.ts b/packages/api-auth-entity-storage-service/tests/processors/authHeaderProcessor.spec.ts index 07dd70e..e5232a3 100644 --- a/packages/api-auth-entity-storage-service/tests/processors/authHeaderProcessor.spec.ts +++ b/packages/api-auth-entity-storage-service/tests/processors/authHeaderProcessor.spec.ts @@ -1,4 +1,5 @@ // Copyright 2026 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import { HttpErrorHelper, type IHttpResponse } from "@3sixty/api-models"; import { ContextIdKeys, ContextIdStore, type IContextIds } from "@3sixty/context"; @@ -60,6 +61,7 @@ describe("AuthHeaderProcessor", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "hashed", @@ -73,11 +75,11 @@ describe("AuthHeaderProcessor", () => { vi.spyOn(TokenHelper, "verify").mockImplementation( async (vault, nodeId, key, token, verifyUser) => { // No tid - single-tenant or tenant-free token; only user+org verified. - const verified = await verifyUser?.("did:user:123", "did:org:456", undefined, 0); + const verified = await verifyUser?.("user-1", "did:org:456", undefined, 0); expect(verified).toEqual(["user", "organization"]); return { header: { alg: "EdDSA" }, - payload: { sub: "did:user:123", org: "did:org:456" } + payload: { sub: "user-1", org: "did:org:456" } }; } ); @@ -89,10 +91,52 @@ describe("AuthHeaderProcessor", () => { await processor.pre({ headers: {} } as never, response, {} as never, contextIds, {}); expect(response.statusCode).toBeUndefined(); + expect(contextIds[ContextIdKeys.Account]).toBe("user-1"); expect(contextIds[ContextIdKeys.User]).toBe("did:user:123"); expect(contextIds[ContextIdKeys.Organization]).toBe("did:org:456"); expect(contextIds[ContextIdKeys.UserOrganization]).toBe("did:org:456"); - expect(mockUserEntityStorage.get).toHaveBeenCalledWith("did:user:123", "identity"); + expect(mockUserEntityStorage.get).toHaveBeenCalledWith("user-1", "userId"); + }); + + it("should set the account key only when the user has no DID", async () => { + vi.spyOn(ContextIdStore, "getContextIds").mockResolvedValue({ + [ContextIdKeys.Node]: "node-1", + [ContextIdKeys.Organization]: "did:org:456" + }); + vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ + email: "user@example.com", + userId: "user-1", + organization: "did:org:456", + password: "hashed", + salt: "salt", + passwordVersion: 0 + }); + vi.spyOn(TokenHelper, "extractTokenFromHeaders").mockReturnValue({ + token: "jwt", + location: "authorization" + }); + vi.spyOn(TokenHelper, "verify").mockImplementation( + async (vault, nodeId, key, token, verifyUser) => { + const verified = await verifyUser?.("user-1", "did:org:456", undefined, 0); + expect(verified).toEqual(["user", "organization"]); + return { + header: { alg: "EdDSA" }, + payload: { sub: "user-1", org: "did:org:456" } + }; + } + ); + + await processor.start(); + + const contextIds: IContextIds = { + [ContextIdKeys.User]: "did:user:stale" + }; + const response: IHttpResponse = {}; + await processor.pre({ headers: {} } as never, response, {} as never, contextIds, {}); + + expect(response.statusCode).toBeUndefined(); + expect(contextIds[ContextIdKeys.Account]).toBe("user-1"); + expect(contextIds[ContextIdKeys.User]).toBeUndefined(); }); it("should set tenant context from token when tenantAdminComponent is configured", async () => { @@ -113,6 +157,7 @@ describe("AuthHeaderProcessor", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "hashed", @@ -126,11 +171,11 @@ describe("AuthHeaderProcessor", () => { const tenantId = "tenant-1"; vi.spyOn(TokenHelper, "verify").mockImplementation( async (vault, nodeId, key, token, verifyUser) => { - const verified = await verifyUser?.("did:user:123", "did:org:456", tenantId, 0); + const verified = await verifyUser?.("user-1", "did:org:456", tenantId, 0); expect(verified).toContain("tenant"); return { header: { alg: "EdDSA" }, - payload: { sub: "did:user:123", org: "did:org:456", tid: tenantId } + payload: { sub: "user-1", org: "did:org:456", tid: tenantId } }; } ); @@ -143,6 +188,7 @@ describe("AuthHeaderProcessor", () => { expect(response.statusCode).toBeUndefined(); expect(contextIds[ContextIdKeys.Tenant]).toBe("tenant-1"); + expect(contextIds[ContextIdKeys.Account]).toBe("user-1"); expect(contextIds[ContextIdKeys.User]).toBe("did:user:123"); expect(contextIds[ContextIdKeys.UserOrganization]).toBe("did:org:456"); }); @@ -158,7 +204,7 @@ describe("AuthHeaderProcessor", () => { }); vi.spyOn(TokenHelper, "verify").mockImplementation( async (vault, nodeId, key, token, verifyUser) => { - await verifyUser?.("did:user:123", "did:org:456", undefined, undefined); + await verifyUser?.("user-1", "did:org:456", undefined, undefined); throw new UnauthorizedError(TokenHelper.CLASS_NAME, "userNotVerified"); } ); @@ -174,6 +220,7 @@ describe("AuthHeaderProcessor", () => { expect(buildResponseSpy).toHaveBeenCalled(); expect(response.statusCode).toBe(HttpStatusCode.unauthorized); + expect(contextIds[ContextIdKeys.Account]).toBeUndefined(); expect(contextIds[ContextIdKeys.User]).toBeUndefined(); }); @@ -211,6 +258,7 @@ describe("AuthHeaderProcessor", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "hashed", @@ -225,13 +273,13 @@ describe("AuthHeaderProcessor", () => { vi.spyOn(TokenHelper, "verify").mockImplementation( async (vault, nodeId, key, token, verifyUser) => { // pver=0 in token but user has passwordVersion=1 - simulate stale token - const verified = await verifyUser?.("did:user:123", "did:org:456", encryptedTenantId, 0); + const verified = await verifyUser?.("user-1", "did:org:456", encryptedTenantId, 0); if (!verified?.includes("user")) { throw new UnauthorizedError(TokenHelper.CLASS_NAME, "userNotVerified"); } return { header: { alg: "EdDSA" }, - payload: { sub: "did:user:123", org: "did:org:456", tid: encryptedTenantId } + payload: { sub: "user-1", org: "did:org:456", tid: encryptedTenantId } }; } ); @@ -247,8 +295,9 @@ describe("AuthHeaderProcessor", () => { expect(buildResponseSpy).toHaveBeenCalled(); expect(response.statusCode).toBe(HttpStatusCode.unauthorized); + expect(contextIds[ContextIdKeys.Account]).toBeUndefined(); expect(contextIds[ContextIdKeys.User]).toBeUndefined(); - expect(mockUserEntityStorage.get).toHaveBeenCalledWith("did:user:123", "identity"); + expect(mockUserEntityStorage.get).toHaveBeenCalledWith("user-1", "userId"); }); it("should succeed when token has no pver and user has no passwordVersion stored", async () => { @@ -258,6 +307,7 @@ describe("AuthHeaderProcessor", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "hashed", @@ -270,11 +320,11 @@ describe("AuthHeaderProcessor", () => { vi.spyOn(TokenHelper, "verify").mockImplementation( async (vault, nodeId, key, token, verifyUser) => { // No tid - pver behaviour is the focus here. - const verified = await verifyUser?.("did:user:123", "did:org:456", undefined, undefined); + const verified = await verifyUser?.("user-1", "did:org:456", undefined, undefined); expect(verified).toEqual(["user", "organization"]); return { header: { alg: "EdDSA" }, - payload: { sub: "did:user:123", org: "did:org:456" } + payload: { sub: "user-1", org: "did:org:456" } }; } ); @@ -286,10 +336,11 @@ describe("AuthHeaderProcessor", () => { await processor.pre({ headers: {} } as never, response, {} as never, contextIds, {}); expect(response.statusCode).toBeUndefined(); + expect(contextIds[ContextIdKeys.Account]).toBe("user-1"); expect(contextIds[ContextIdKeys.User]).toBe("did:user:123"); expect(contextIds[ContextIdKeys.Organization]).toBe("did:org:456"); expect(contextIds[ContextIdKeys.UserOrganization]).toBe("did:org:456"); - expect(mockUserEntityStorage.get).toHaveBeenCalledWith("did:user:123", "identity"); + expect(mockUserEntityStorage.get).toHaveBeenCalledWith("user-1", "userId"); }); it("should return unauthorized when token has an encrypted tenant but tenantAdminComponent is not configured", async () => { @@ -298,6 +349,7 @@ describe("AuthHeaderProcessor", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "hashed", @@ -311,13 +363,13 @@ describe("AuthHeaderProcessor", () => { const encryptedTenantId = "encrypted:tenant-1"; vi.spyOn(TokenHelper, "verify").mockImplementation( async (vault, nodeId, key, token, verifyUser) => { - const verified = await verifyUser?.("did:user:123", "did:org:456", encryptedTenantId, 0); + const verified = await verifyUser?.("user-1", "did:org:456", encryptedTenantId, 0); if (!verified?.includes("tenant")) { throw new UnauthorizedError(TokenHelper.CLASS_NAME, "tenantNotVerified"); } return { header: { alg: "EdDSA" }, - payload: { sub: "did:user:123", org: "did:org:456", tid: encryptedTenantId } + payload: { sub: "user-1", org: "did:org:456", tid: encryptedTenantId } }; } ); @@ -331,6 +383,7 @@ describe("AuthHeaderProcessor", () => { expect(buildResponseSpy).toHaveBeenCalled(); expect(response.statusCode).toBe(HttpStatusCode.unauthorized); + expect(contextIds[ContextIdKeys.Account]).toBeUndefined(); expect(contextIds[ContextIdKeys.User]).toBeUndefined(); }); @@ -352,6 +405,7 @@ describe("AuthHeaderProcessor", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "hashed", @@ -365,11 +419,11 @@ describe("AuthHeaderProcessor", () => { const tenantA = "tenant-a"; vi.spyOn(TokenHelper, "verify").mockImplementation( async (vault, nodeId, key, token, verifyUser) => { - const verified = await verifyUser?.("did:user:123", "did:org:456", tenantA, 0); + const verified = await verifyUser?.("user-1", "did:org:456", tenantA, 0); expect(verified).toContain("tenant"); return { header: { alg: "EdDSA" }, - payload: { sub: "did:user:123", org: "did:org:456", tid: tenantA } + payload: { sub: "user-1", org: "did:org:456", tid: tenantA } }; } ); @@ -385,6 +439,7 @@ describe("AuthHeaderProcessor", () => { expect(response.statusCode).toBeUndefined(); expect(contextIds[ContextIdKeys.Tenant]).toBe("tenant-a"); + expect(contextIds[ContextIdKeys.Account]).toBe("user-1"); expect(contextIds[ContextIdKeys.User]).toBe("did:user:123"); expect(contextIds[ContextIdKeys.UserOrganization]).toBe("did:org:456"); }); @@ -396,6 +451,7 @@ describe("AuthHeaderProcessor", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "hashed", @@ -409,11 +465,11 @@ describe("AuthHeaderProcessor", () => { vi.spyOn(TokenHelper, "verify").mockImplementation( async (vault, nodeId, key, token, verifyUser) => { // No tid in token - single-tenant system, no tenant verification required. - const verified = await verifyUser?.("did:user:123", "did:org:456", undefined, 0); + const verified = await verifyUser?.("user-1", "did:org:456", undefined, 0); expect(verified).toEqual(["user", "organization"]); return { header: { alg: "EdDSA" }, - payload: { sub: "did:user:123", org: "did:org:456" } + payload: { sub: "user-1", org: "did:org:456" } }; } ); @@ -426,6 +482,7 @@ describe("AuthHeaderProcessor", () => { await processor.pre({ headers: {} } as never, response, {} as never, contextIds, {}); expect(response.statusCode).toBeUndefined(); + expect(contextIds[ContextIdKeys.Account]).toBe("user-1"); expect(contextIds[ContextIdKeys.User]).toBe("did:user:123"); expect(contextIds[ContextIdKeys.Organization]).toBe("did:org:456"); expect(contextIds[ContextIdKeys.UserOrganization]).toBe("did:org:456"); @@ -448,6 +505,7 @@ describe("AuthHeaderProcessor", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: USER_IDENTITY, organization: USER_ORG, password: "hashed", @@ -463,7 +521,7 @@ describe("AuthHeaderProcessor", () => { it("sets Organization context to _nodeOrganizationId when no tenant ID is in the token", async () => { vi.spyOn(TokenHelper, "verify").mockImplementation( async (vault, nodeId, key, token, verifyUser) => { - await verifyUser?.(USER_IDENTITY, USER_ORG, undefined, 0); + await verifyUser?.("user-1", USER_ORG, undefined, 0); return { header: { alg: "EdDSA" }, payload: {} }; } ); @@ -481,7 +539,7 @@ describe("AuthHeaderProcessor", () => { it("sets UserOrganization to the user entity's own organization on successful verify", async () => { vi.spyOn(TokenHelper, "verify").mockImplementation( async (vault, nodeId, key, token, verifyUser) => { - await verifyUser?.(USER_IDENTITY, USER_ORG, undefined, 0); + await verifyUser?.("user-1", USER_ORG, undefined, 0); return { header: { alg: "EdDSA" }, payload: {} }; } ); @@ -512,7 +570,7 @@ describe("AuthHeaderProcessor", () => { vi.spyOn(TokenHelper, "verify").mockImplementation( async (vault, nodeId, key, token, verifyUser) => { - await verifyUser?.(USER_IDENTITY, USER_ORG, "tenant-1", 0); + await verifyUser?.("user-1", USER_ORG, "tenant-1", 0); return { header: { alg: "EdDSA" }, payload: {} }; } ); @@ -618,6 +676,7 @@ describe("AuthHeaderProcessor", () => { describe("token cache", () => { const USER: AuthenticationUser = { email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "hashed", @@ -633,10 +692,10 @@ describe("AuthHeaderProcessor", () => { function mockVerify(expSeconds?: number): void { vi.spyOn(TokenHelper, "verify").mockImplementation( async (vault, nodeId, key, token, verifyUser) => { - await verifyUser?.("did:user:123", "did:org:456", undefined, 0); + await verifyUser?.("user-1", "did:org:456", undefined, 0); return { header: { alg: "EdDSA" }, - payload: { sub: "did:user:123", org: "did:org:456", exp: expSeconds } + payload: { sub: "user-1", org: "did:org:456", exp: expSeconds } }; } ); @@ -680,6 +739,7 @@ describe("AuthHeaderProcessor", () => { for (let i = 0; i < 3; i++) { const { response, contextIds } = await runRequest(authProcessor); expect(response.statusCode).toBeUndefined(); + expect(contextIds[ContextIdKeys.Account]).toBe("user-1"); expect(contextIds[ContextIdKeys.User]).toBe("did:user:123"); expect(contextIds[ContextIdKeys.UserOrganization]).toBe("did:org:456"); } @@ -698,11 +758,11 @@ describe("AuthHeaderProcessor", () => { inFlight++; maxInFlight = Math.max(maxInFlight, inFlight); await new Promise(resolve => setTimeout(resolve, 20)); - await verifyUser?.("did:user:123", "did:org:456", undefined, 0); + await verifyUser?.("user-1", "did:org:456", undefined, 0); inFlight--; return { header: { alg: "EdDSA" }, - payload: { sub: "did:user:123", org: "did:org:456" } + payload: { sub: "user-1", org: "did:org:456" } }; } ); diff --git a/packages/api-auth-entity-storage-service/tests/routes/entityStorageAuthenticationRoutes.spec.ts b/packages/api-auth-entity-storage-service/tests/routes/entityStorageAuthenticationRoutes.spec.ts new file mode 100644 index 0000000..997e990 --- /dev/null +++ b/packages/api-auth-entity-storage-service/tests/routes/entityStorageAuthenticationRoutes.spec.ts @@ -0,0 +1,97 @@ +// Copyright 2026 3Sixty Trade Ltd. +// SPDX-License-Identifier: Apache-2.0. +import type { IHttpRequestContext, IHttpResponse } from "@3sixty/api-models"; +import { ComponentFactory } from "@3sixty/core"; +import { + EntityStorageConnectorFactory, + type IEntityStorageConnector +} from "@3sixty/entity-storage-models"; +import { VaultConnectorFactory, type IVaultConnector } from "@3sixty/vault-models"; +import { HeaderTypes } from "@3sixty/web"; +import { AuthHeaderProcessor } from "../../src/processors/authHeaderProcessor.js"; +import { + authenticationAdoptIdentity, + generateRestRoutesAuthentication +} from "../../src/routes/entityStorageAuthenticationRoutes.js"; + +describe("authenticationAdoptIdentity", () => { + beforeEach(() => { + vi.restoreAllMocks(); + const entityStorage = {} as unknown as IEntityStorageConnector; + const vault = {} as unknown as IVaultConnector; + vi.spyOn(EntityStorageConnectorFactory, "get").mockReturnValue(entityStorage); + vi.spyOn(VaultConnectorFactory, "get").mockReturnValue(vault); + vi.spyOn(ComponentFactory, "getIfExists").mockReturnValue(undefined); + }); + + it("should leave the session untouched for a bearer caller", async () => { + const authentication = { + className: () => "authentication", + adoptIdentity: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(ComponentFactory, "get").mockReturnValue(authentication); + const httpRequestContext = { + serverRequest: { headers: {} }, + processorState: { + authTokenLocation: "authorization" + } + } as unknown as IHttpRequestContext; + + const result = await authenticationAdoptIdentity(httpRequestContext, "authentication"); + const response: IHttpResponse = {}; + const processor = new AuthHeaderProcessor(); + await processor.post( + { headers: {} } as never, + response, + {} as never, + {}, + httpRequestContext.processorState + ); + + expect(result.statusCode).toBe(204); + expect(httpRequestContext.processorState.authOperation).toBeUndefined(); + expect(httpRequestContext.processorState.authToken).toBeUndefined(); + expect(response.headers?.[HeaderTypes.SetCookie]).toBeUndefined(); + }); + + it("should leave the session cookie untouched when the caller did not use a bearer token", async () => { + const authentication = { + className: () => "authentication", + adoptIdentity: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(ComponentFactory, "get").mockReturnValue(authentication); + const httpRequestContext = { + serverRequest: { headers: {} }, + processorState: {} + } as unknown as IHttpRequestContext; + + const result = await authenticationAdoptIdentity(httpRequestContext, "authentication"); + const response: IHttpResponse = {}; + const processor = new AuthHeaderProcessor(); + await processor.post( + { headers: {} } as never, + response, + {} as never, + {}, + httpRequestContext.processorState + ); + + expect(result.statusCode).toBe(204); + expect(response.headers?.[HeaderTypes.SetCookie]).toBeUndefined(); + }); +}); + +describe("authentication routes before a DID exists", () => { + it("should stay authenticated without an authorization subject", () => { + const routes = generateRestRoutesAuthentication("/authentication", "authentication"); + const updatePassword = routes.find( + route => route.operationId === "authenticationUpdatePassword" + ); + const adoptIdentity = routes.find(route => route.operationId === "authenticationAdoptIdentity"); + + expect(updatePassword?.requiresAuthorization).toBe(false); + expect(updatePassword?.skipAuth).toBeUndefined(); + expect(adoptIdentity?.requiresAuthorization).toBe(false); + expect(adoptIdentity?.skipAuth).toBeUndefined(); + }); +}); diff --git a/packages/api-auth-entity-storage-service/tests/schema.spec.ts b/packages/api-auth-entity-storage-service/tests/schema.spec.ts new file mode 100644 index 0000000..7fcbeb9 --- /dev/null +++ b/packages/api-auth-entity-storage-service/tests/schema.spec.ts @@ -0,0 +1,41 @@ +// Copyright 2026 3Sixty Trade Ltd. +// SPDX-License-Identifier: Apache-2.0. +import { RandomHelper } from "@3sixty/core"; +import { initSchema } from "../src/schema.js"; +import { migrateAuthenticationUser } from "./helpers/migrateAuthenticationUser.js"; + +initSchema(); + +describe("AuthenticationUser migration", () => { + const storedAccount = { + email: "user@example.com", + password: "stored-password-hash", + salt: "c2FsdA==", + identity: "did:user:123", + organization: "did:org:456", + passwordVersion: 2 + }; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(RandomHelper, "generateUuidV7").mockReturnValue("account-1"); + }); + + test("gives a version 1 account its own id and keeps the identity", async () => { + const migrated = await migrateAuthenticationUser(storedAccount, 1); + + expect(migrated.userId).toBe("account-1"); + expect(migrated.identity).toBe("did:user:123"); + expect(migrated.email).toBe("user@example.com"); + expect(migrated.organization).toBe("did:org:456"); + expect(migrated.passwordVersion).toBe(2); + }); + + test("drops the stored scope from a version 0 account and keeps the identity", async () => { + const migrated = await migrateAuthenticationUser({ ...storedAccount, scope: "read" }, 0); + + expect(migrated.userId).toBe("account-1"); + expect(migrated.identity).toBe("did:user:123"); + expect(migrated).not.toHaveProperty("scope"); + }); +}); diff --git a/packages/api-auth-entity-storage-service/tests/services/entityStorageAuthenticationAdminService.spec.ts b/packages/api-auth-entity-storage-service/tests/services/entityStorageAuthenticationAdminService.spec.ts index 3ac283c..c11c984 100644 --- a/packages/api-auth-entity-storage-service/tests/services/entityStorageAuthenticationAdminService.spec.ts +++ b/packages/api-auth-entity-storage-service/tests/services/entityStorageAuthenticationAdminService.spec.ts @@ -1,4 +1,5 @@ // Copyright 2026 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import type { IAuthenticationAuditComponent } from "@3sixty/api-auth-models"; import { ForbiddenError } from "@3sixty/api-models"; @@ -22,6 +23,7 @@ describe("EntityStorageAuthenticationAdminService", () => { beforeEach(() => { vi.restoreAllMocks(); + vi.spyOn(RandomHelper, "generateUuidV7").mockReturnValue("user-1"); mockAuthenticationAuditService = { className: vi.fn().mockReturnValue("AuthenticationAuditService"), @@ -60,11 +62,171 @@ describe("EntityStorageAuthenticationAdminService", () => { expect(service.className()).toBe(EntityStorageAuthenticationAdminService.CLASS_NAME); }); + it("should create a user when a DID and organisation are provided", async () => { + vi.spyOn(PasswordValidator, "validatePassword").mockImplementation(() => {}); + vi.spyOn(RandomHelper, "generate").mockReturnValue(new Uint8Array([1, 2, 3, 4])); + vi.spyOn(PasswordGenerator, "hashPassword").mockResolvedValue("hashed-password"); + + await service.create({ + email: "user@example.com", + password: "correct-horse-battery", + userIdentity: "did:user:123", + organizationIdentity: "did:org:456" + }); + + expect(PasswordValidator.validatePassword).toHaveBeenCalledWith("correct-horse-battery", { + minLength: 10 + }); + expect(await userEntityStorage.get("user@example.com")).toEqual({ + email: "user@example.com", + salt: "AQIDBA==", + password: "hashed-password", + userId: "user-1", + identity: "did:user:123", + organization: "did:org:456", + passwordVersion: 0 + }); + expect(mockAuthenticationAuditService.create).toHaveBeenCalledWith({ + actorId: "user@example.com", + event: "account-created", + data: { + userId: "user-1", + userIdentity: "did:user:123", + organizationIdentity: "did:org:456", + tenantId: undefined, + originalTenantId: undefined + } + }); + }); + + it("should return the generated account id when a user is loaded by DID", async () => { + vi.spyOn(PasswordValidator, "validatePassword").mockImplementation(() => {}); + vi.spyOn(RandomHelper, "generate").mockReturnValue(new Uint8Array([1, 2, 3, 4])); + vi.spyOn(PasswordGenerator, "hashPassword").mockResolvedValue("hashed-password"); + + await service.create({ + email: "user@example.com", + password: "correct-horse-battery", + userIdentity: "did:user:123", + organizationIdentity: "did:org:456" + }); + + const result = await service.getByIdentity("did:user:123"); + + expect(result.userId).toBe("user-1"); + expect(result.userId).not.toBe(result.userIdentity); + expect(result.userIdentity).toBe("did:user:123"); + }); + + it("should store the email and the context organisation when identities are omitted", async () => { + vi.spyOn(PasswordValidator, "validatePassword").mockImplementation(() => {}); + vi.spyOn(RandomHelper, "generate").mockReturnValue(new Uint8Array([1, 2, 3, 4])); + vi.spyOn(PasswordGenerator, "hashPassword").mockResolvedValue("hashed-password"); + + await ContextIdStore.run({ [ContextIdKeys.Organization]: "did:org:node" }, async () => { + await service.create({ + email: "User@Example.com", + password: "correct-horse-battery" + }); + }); + + expect(await userEntityStorage.get("User@Example.com")).toEqual({ + email: "User@Example.com", + salt: "AQIDBA==", + password: "hashed-password", + userId: "user-1", + organization: "did:org:node", + passwordVersion: 0 + }); + expect(mockAuthenticationAuditService.create).toHaveBeenCalledWith({ + actorId: "User@Example.com", + event: "account-created", + data: { + userId: "user-1", + userIdentity: undefined, + organizationIdentity: "did:org:node", + tenantId: undefined, + originalTenantId: undefined + } + }); + }); + + it("should create two accounts without a DID and give each its own id", async () => { + vi.spyOn(PasswordValidator, "validatePassword").mockImplementation(() => {}); + vi.spyOn(RandomHelper, "generate").mockReturnValue(new Uint8Array([1, 2, 3, 4])); + vi.spyOn(PasswordGenerator, "hashPassword").mockResolvedValue("hashed-password"); + vi.spyOn(RandomHelper, "generateUuidV7") + .mockReturnValueOnce("user-1") + .mockReturnValueOnce("user-2"); + + await ContextIdStore.run({ [ContextIdKeys.Organization]: "did:org:node" }, async () => { + await service.create({ + email: "User@Example.com", + password: "correct-horse-battery" + }); + await service.create({ + email: "other@example.com", + password: "correct-horse-battery" + }); + }); + + expect(await userEntityStorage.get("User@Example.com")).toMatchObject({ + userId: "user-1", + email: "User@Example.com" + }); + expect(await userEntityStorage.get("other@example.com")).toMatchObject({ + userId: "user-2", + email: "other@example.com" + }); + expect((await userEntityStorage.get("User@Example.com"))?.identity).toBeUndefined(); + expect((await userEntityStorage.get("other@example.com"))?.identity).toBeUndefined(); + }); + + it("should reject a blank identity and write no user", async () => { + vi.spyOn(PasswordValidator, "validatePassword").mockImplementation(() => {}); + + await expect( + service.create({ + email: "user@example.com", + password: "correct-horse-battery", + userIdentity: "", + organizationIdentity: "did:org:456" + }) + ).rejects.toThrow(); + expect(await userEntityStorage.get("user@example.com")).toBeUndefined(); + expect(mockAuthenticationAuditService.create).not.toHaveBeenCalled(); + }); + + it("should reject create when no organisation is available and write no user", async () => { + vi.spyOn(PasswordValidator, "validatePassword").mockImplementation(() => {}); + + await expect( + ContextIdStore.run({}, async () => { + await service.create({ + email: "user@example.com", + password: "correct-horse-battery" + }); + }) + ).rejects.toThrow(); + expect(await userEntityStorage.get("user@example.com")).toBeUndefined(); + const rejection = await service + .create({ + email: "user@example.com", + password: "correct-horse-battery" + }) + .then( + () => "", + (error: Error) => error.message + ); + expect(rejection).not.toContain("correct-horse-battery"); + }); + it("should wrap create failures when the user already exists", async () => { await userEntityStorage.set({ email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -99,6 +261,43 @@ describe("EntityStorageAuthenticationAdminService", () => { expect(await userEntityStorage.get("user@example.com")).toBeUndefined(); }); + it("should update an existing user's organisation", async () => { + await userEntityStorage.set({ + email: "user@example.com", + password: "stored-password", + salt: "AQIDBA==", + userId: "user-1", + identity: "did:user:123", + organization: "did:org:456" + }); + + await service.update({ + email: "user@example.com", + organizationIdentity: "did:org:999" + }); + + expect(await userEntityStorage.get("user@example.com")).toEqual({ + email: "user@example.com", + password: "stored-password", + salt: "AQIDBA==", + userId: "user-1", + identity: "did:user:123", + organization: "did:org:999" + }); + expect(mockAuthenticationAuditService.create).toHaveBeenCalledWith({ + actorId: "user@example.com", + event: "account-updated", + data: { + updatedFields: ["organizationIdentity"], + userId: "user-1", + userIdentity: "did:user:123", + organizationIdentity: "did:org:999", + tenantId: undefined, + originalTenantId: undefined + } + }); + }); + it("should wrap update failures when the user is missing", async () => { await expect( service.update({ @@ -114,6 +313,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -122,6 +322,7 @@ describe("EntityStorageAuthenticationAdminService", () => { expect(result).toEqual({ email: "user@example.com", + userId: "user-1", userIdentity: "did:user:123", organizationIdentity: "did:org:456" }); @@ -132,6 +333,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -140,6 +342,7 @@ describe("EntityStorageAuthenticationAdminService", () => { expect(result).toEqual({ email: "user@example.com", + userId: "user-1", userIdentity: "did:user:123", organizationIdentity: "did:org:456" }); @@ -158,6 +361,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -169,8 +373,11 @@ describe("EntityStorageAuthenticationAdminService", () => { actorId: "user@example.com", event: "account-deleted", data: { + userId: "user-1", userIdentity: "did:user:123", - organizationIdentity: "did:org:456" + organizationIdentity: "did:org:456", + tenantId: undefined, + originalTenantId: undefined } }); }); @@ -187,6 +394,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -203,6 +411,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", salt: "CQgHBg==", password: "new-password-hash", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", passwordVersion: 1 @@ -211,6 +420,7 @@ describe("EntityStorageAuthenticationAdminService", () => { actorId: "user@example.com", event: "password-changed", data: { + userId: "user-1", userIdentity: "did:user:123", organizationIdentity: "did:org:456" } @@ -222,6 +432,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -237,6 +448,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", salt: "BQYHCA==", password: "new-password-hash", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", passwordVersion: 1 @@ -248,6 +460,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -319,6 +532,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", salt: "AQIDBA==", password: "hashed-password", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", passwordVersion: 0 @@ -332,6 +546,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -340,6 +555,7 @@ describe("EntityStorageAuthenticationAdminService", () => { expect(result).toEqual({ email: "user@example.com", + userId: "user-1", userIdentity: "did:user:123", organizationIdentity: "did:org:456" }); @@ -352,6 +568,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -360,6 +577,7 @@ describe("EntityStorageAuthenticationAdminService", () => { expect(result).toEqual({ email: "user@example.com", + userId: "user-1", userIdentity: "did:user:123", organizationIdentity: "did:org:456" }); @@ -372,6 +590,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -393,6 +612,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -413,6 +633,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -467,6 +688,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -483,6 +705,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -499,6 +722,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -523,6 +747,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -545,6 +770,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "user@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "user-1", identity: "did:user:123", organization: "did:org:456" }); @@ -612,6 +838,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "admin@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "admin-1", identity: ESCALATED_IDENTITY, organization: "did:org:456" }); @@ -619,6 +846,7 @@ describe("EntityStorageAuthenticationAdminService", () => { email: "plain@example.com", password: "stored-password", salt: "AQIDBA==", + userId: "plain-1", identity: PLAIN_IDENTITY, organization: "did:org:456" }); diff --git a/packages/api-auth-entity-storage-service/tests/services/entityStorageAuthenticationService.spec.ts b/packages/api-auth-entity-storage-service/tests/services/entityStorageAuthenticationService.spec.ts index 9f81cf0..fe55c50 100644 --- a/packages/api-auth-entity-storage-service/tests/services/entityStorageAuthenticationService.spec.ts +++ b/packages/api-auth-entity-storage-service/tests/services/entityStorageAuthenticationService.spec.ts @@ -1,4 +1,5 @@ // Copyright 2026 IOTA Stiftung. +// Copyright 2026 3Sixty Trade Ltd. // SPDX-License-Identifier: Apache-2.0. import type { IAuthenticationAuditComponent, @@ -6,7 +7,7 @@ import type { } from "@3sixty/api-auth-models"; import { TooManyRequestsError } from "@3sixty/api-models"; import { ContextIdKeys, ContextIdStore } from "@3sixty/context"; -import { ComponentFactory, NotFoundError, UnauthorizedError } from "@3sixty/core"; +import { ComponentFactory, ConflictError, NotFoundError, UnauthorizedError } from "@3sixty/core"; import { PasswordGenerator, PasswordValidator } from "@3sixty/crypto"; import { MemoryEntityStorageConnector } from "@3sixty/entity-storage-connector-memory"; import { @@ -20,6 +21,7 @@ import { initSchema } from "../../src/schema.js"; import { EntityStorageAuthenticationService } from "../../src/services/entityStorageAuthenticationService.js"; import { PasswordHelper } from "../../src/utils/passwordHelper.js"; import { TokenHelper } from "../../src/utils/tokenHelper.js"; +import { migrateAuthenticationUser } from "../helpers/migrateAuthenticationUser.js"; initSchema(); @@ -95,6 +97,7 @@ describe("EntityStorageAuthenticationService", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "stored-password-hash", @@ -116,7 +119,7 @@ describe("EntityStorageAuthenticationService", () => { mockVaultConnector, "node-1", "auth-signing", - "did:user:123", + "user-1", "did:org:456", "tenant-1", 60, @@ -127,6 +130,7 @@ describe("EntityStorageAuthenticationService", () => { actorId: "user@example.com", event: "login-success", data: { + userId: "user-1", userIdentity: "did:user:123", organizationIdentity: "did:org:456", tenantId: "tenant-1" @@ -134,12 +138,89 @@ describe("EntityStorageAuthenticationService", () => { }); }); + it("should login a stored account with the id allocated by migration", async () => { + const migrated = await migrateAuthenticationUser( + { + email: "user@example.com", + password: "stored-password-hash", + salt: "c2FsdA==", + identity: "did:user:123", + organization: "did:org:456", + scope: "read" + }, + 0 + ); + vi.spyOn(ContextIdStore, "getContextIds").mockResolvedValue({ + [ContextIdKeys.Node]: "node-1", + [ContextIdKeys.Tenant]: "tenant-1" + }); + vi.mocked(mockUserEntityStorage.get).mockResolvedValue(migrated); + vi.spyOn(PasswordGenerator, "hashPassword").mockResolvedValue("generated-password-hash"); + vi.spyOn(PasswordValidator, "comparePasswordHashes").mockReturnValue(true); + vi.spyOn(TokenHelper, "createToken").mockResolvedValue({ + token: "jwt-token", + expiry: 123456789 + }); + + await service.start(); + await service.login("user@example.com", "correct-password"); + + expect(migrated.userId).toEqual(expect.any(String)); + expect(migrated.userId).not.toBe("did:user:123"); + expect(migrated.identity).toBe("did:user:123"); + expect(migrated).not.toHaveProperty("scope"); + expect(TokenHelper.createToken).toHaveBeenCalledWith( + mockVaultConnector, + "node-1", + "auth-signing", + migrated.userId, + "did:org:456", + "tenant-1", + 60, + 0 + ); + }); + + it("should login with the account id when the account has no DID", async () => { + vi.spyOn(ContextIdStore, "getContextIds").mockResolvedValue({ + [ContextIdKeys.Node]: "node-1" + }); + vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ + email: "user@example.com", + userId: "user-1", + organization: "did:org:456", + password: "stored-password-hash", + salt: "c2FsdA==" + }); + vi.spyOn(PasswordGenerator, "hashPassword").mockResolvedValue("generated-password-hash"); + vi.spyOn(PasswordValidator, "comparePasswordHashes").mockReturnValue(true); + vi.spyOn(TokenHelper, "createToken").mockResolvedValue({ + token: "jwt-token", + expiry: 123456789 + }); + + await service.start(); + await service.login("user@example.com", "correct-password"); + + expect(TokenHelper.createToken).toHaveBeenCalledWith( + mockVaultConnector, + "node-1", + "auth-signing", + "user-1", + "did:org:456", + undefined, + 60, + 0 + ); + }); + it("should throw UnauthorizedError when login password does not match", async () => { vi.spyOn(ContextIdStore, "getContextIds").mockResolvedValue({ [ContextIdKeys.Node]: "node-1" }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "stored-password-hash", @@ -180,6 +261,7 @@ describe("EntityStorageAuthenticationService", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "stored-password-hash", @@ -215,6 +297,10 @@ describe("EntityStorageAuthenticationService", () => { maxAttempts: 30, windowMinutes: 60 }); + expect(mockAuthenticationRateService.registerAction).toHaveBeenCalledWith("identity-adopt", { + maxAttempts: 5, + windowMinutes: 15 + }); }); it("should register login rate action on start when configured", async () => { @@ -244,6 +330,7 @@ describe("EntityStorageAuthenticationService", () => { expect(mockAuthenticationRateService.unregisterAction).toHaveBeenCalledWith("login"); expect(mockAuthenticationRateService.unregisterAction).toHaveBeenCalledWith("password-change"); expect(mockAuthenticationRateService.unregisterAction).toHaveBeenCalledWith("token-refresh"); + expect(mockAuthenticationRateService.unregisterAction).toHaveBeenCalledWith("identity-adopt"); }); it("should wrap rate limit check failures during login", async () => { @@ -260,6 +347,7 @@ describe("EntityStorageAuthenticationService", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "stored-password-hash", @@ -295,6 +383,7 @@ describe("EntityStorageAuthenticationService", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "stored-password-hash", @@ -302,12 +391,12 @@ describe("EntityStorageAuthenticationService", () => { }); vi.spyOn(TokenHelper, "verify").mockImplementation( async (vaultConnector, nodeId, signingKeyName, token, verifyUser) => { - const verified = await verifyUser?.("did:user:123", "did:org:456", undefined, 0); + const verified = await verifyUser?.("user-1", "did:org:456", undefined, 0); expect(verified).toEqual(["user", "organization"]); return { header: { alg: "EdDSA" }, payload: { - sub: "did:user:123", + sub: "user-1", org: "did:org:456" } }; @@ -322,24 +411,21 @@ describe("EntityStorageAuthenticationService", () => { const result = await service.refresh("existing-token"); expect(result).toEqual({ token: "refreshed-token", expiry: 987654321 }); - expect(mockUserEntityStorage.get).toHaveBeenCalledWith("did:user:123", "identity"); + expect(mockUserEntityStorage.get).toHaveBeenCalledWith("user-1", "userId"); expect(TokenHelper.createToken).toHaveBeenCalledWith( mockVaultConnector, "node-1", "auth-signing", - "did:user:123", + "user-1", "did:org:456", undefined, 60, 0 ); - expect(mockAuthenticationRateService.check).toHaveBeenCalledWith( - "token-refresh", - "did:user:123" - ); + expect(mockAuthenticationRateService.check).toHaveBeenCalledWith("token-refresh", "user-1"); expect(mockAuthenticationRateService.clear).not.toHaveBeenCalled(); expect(mockAuthenticationAuditService.create).toHaveBeenCalledWith({ - actorId: "did:user:123", + actorId: "user-1", event: "token-refreshed", data: { organizationIdentity: "did:org:456", @@ -355,11 +441,11 @@ describe("EntityStorageAuthenticationService", () => { }); vi.spyOn(TokenHelper, "verify").mockImplementation( async (vaultConnector, nodeId, signingKeyName, token, verifyUser) => { - await verifyUser?.("did:user:123", "did:org:456", undefined, undefined); + await verifyUser?.("user-1", "did:org:456", undefined, undefined); return { header: { alg: "EdDSA" }, payload: { - sub: "did:user:123", + sub: "user-1", org: "did:org:456", tid: "tenant-1" } @@ -404,6 +490,7 @@ describe("EntityStorageAuthenticationService", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:other", organization: "did:org:456", password: "stored-password-hash", @@ -412,7 +499,7 @@ describe("EntityStorageAuthenticationService", () => { const createTokenSpy = vi.spyOn(TokenHelper, "createToken"); vi.spyOn(TokenHelper, "verify").mockImplementation( async (vaultConnector, nodeId, signingKeyName, token, verifyUser) => { - const verified = await verifyUser?.("did:user:123", "did:org:456", undefined, 0); + const verified = await verifyUser?.("user-other", "did:org:456", undefined, 0); if (!verified?.includes("user")) { throw new UnauthorizedError(TokenHelper.CLASS_NAME, "userNotVerified"); } @@ -420,7 +507,7 @@ describe("EntityStorageAuthenticationService", () => { return { header: { alg: "EdDSA" }, payload: { - sub: "did:user:123", + sub: "user-other", org: "did:org:456" } }; @@ -430,7 +517,7 @@ describe("EntityStorageAuthenticationService", () => { await service.start(); await expect(service.refresh("existing-token")).rejects.toThrow(UnauthorizedError); - expect(mockUserEntityStorage.get).toHaveBeenCalledWith("did:user:123", "identity"); + expect(mockUserEntityStorage.get).toHaveBeenCalledWith("user-other", "userId"); expect(createTokenSpy).not.toHaveBeenCalled(); }); @@ -440,6 +527,7 @@ describe("EntityStorageAuthenticationService", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:other", password: "stored-password-hash", @@ -448,7 +536,7 @@ describe("EntityStorageAuthenticationService", () => { const createTokenSpy = vi.spyOn(TokenHelper, "createToken"); vi.spyOn(TokenHelper, "verify").mockImplementation( async (vaultConnector, nodeId, signingKeyName, token, verifyUser) => { - const verified = await verifyUser?.("did:user:123", "did:org:456", undefined, 0); + const verified = await verifyUser?.("user-1", "did:org:456", undefined, 0); if (!verified?.includes("organization")) { throw new UnauthorizedError(TokenHelper.CLASS_NAME, "organizationNotVerified"); } @@ -456,7 +544,7 @@ describe("EntityStorageAuthenticationService", () => { return { header: { alg: "EdDSA" }, payload: { - sub: "did:user:123", + sub: "user-1", org: "did:org:456" } }; @@ -466,7 +554,7 @@ describe("EntityStorageAuthenticationService", () => { await service.start(); await expect(service.refresh("existing-token")).rejects.toThrow(UnauthorizedError); - expect(mockUserEntityStorage.get).toHaveBeenCalledWith("did:user:123", "identity"); + expect(mockUserEntityStorage.get).toHaveBeenCalledWith("user-1", "userId"); expect(createTokenSpy).not.toHaveBeenCalled(); }); @@ -476,6 +564,7 @@ describe("EntityStorageAuthenticationService", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "stored-password-hash", @@ -486,13 +575,13 @@ describe("EntityStorageAuthenticationService", () => { vi.spyOn(TokenHelper, "verify").mockImplementation( async (vaultConnector, nodeId, signingKeyName, token, verifyUser) => { // pver=1 in token but user has passwordVersion=2 - simulate stale token - const verified = await verifyUser?.("did:user:123", "did:org:456", undefined, 1); + const verified = await verifyUser?.("user-1", "did:org:456", undefined, 1); if (!verified?.includes("user")) { throw new UnauthorizedError(TokenHelper.CLASS_NAME, "userNotVerified"); } return { header: { alg: "EdDSA" }, - payload: { sub: "did:user:123", org: "did:org:456" } + payload: { sub: "user-1", org: "did:org:456" } }; } ); @@ -500,16 +589,17 @@ describe("EntityStorageAuthenticationService", () => { await service.start(); await expect(service.refresh("existing-token")).rejects.toThrow(UnauthorizedError); - expect(mockUserEntityStorage.get).toHaveBeenCalledWith("did:user:123", "identity"); + expect(mockUserEntityStorage.get).toHaveBeenCalledWith("user-1", "userId"); expect(createTokenSpy).not.toHaveBeenCalled(); }); it("should update the password for the current user", async () => { vi.spyOn(ContextIdStore, "getContextIds").mockResolvedValue({ - [ContextIdKeys.User]: "did:user:123" + [ContextIdKeys.Account]: "user-1" }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "stored-password-hash", @@ -519,11 +609,8 @@ describe("EntityStorageAuthenticationService", () => { await service.updatePassword("current-password", "new-password"); - expect(mockAuthenticationRateService.check).toHaveBeenCalledWith( - "password-change", - "did:user:123" - ); - expect(mockUserEntityStorage.get).toHaveBeenCalledWith("did:user:123", "identity"); + expect(mockAuthenticationRateService.check).toHaveBeenCalledWith("password-change", "user-1"); + expect(mockUserEntityStorage.get).toHaveBeenCalledWith("user-1", "userId"); expect(PasswordHelper.updatePassword).toHaveBeenCalledWith( mockUserEntityStorage, mockAuthenticationAuditService, @@ -532,15 +619,12 @@ describe("EntityStorageAuthenticationService", () => { "current-password", { minPasswordLength: undefined, maxPasswordLength: undefined } ); - expect(mockAuthenticationRateService.clear).toHaveBeenCalledWith( - "password-change", - "did:user:123" - ); + expect(mockAuthenticationRateService.clear).toHaveBeenCalledWith("password-change", "user-1"); }); it("should throw TooManyRequestsError when password change rate limit is exceeded", async () => { vi.spyOn(ContextIdStore, "getContextIds").mockResolvedValue({ - [ContextIdKeys.User]: "did:user:123" + [ContextIdKeys.Account]: "user-1" }); vi.mocked(mockAuthenticationRateService.check).mockRejectedValue( new TooManyRequestsError( @@ -560,7 +644,7 @@ describe("EntityStorageAuthenticationService", () => { it("should throw NotFoundError when updating the password for an unknown user", async () => { vi.spyOn(ContextIdStore, "getContextIds").mockResolvedValue({ - [ContextIdKeys.User]: "did:user:missing" + [ContextIdKeys.Account]: "user-missing" }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue(undefined); @@ -572,12 +656,12 @@ describe("EntityStorageAuthenticationService", () => { it("should logout without throwing", async () => { vi.spyOn(ContextIdStore, "getContextIds").mockResolvedValue({ - [ContextIdKeys.User]: "did:user:123" + [ContextIdKeys.Account]: "user-1" }); await expect(service.logout("token")).resolves.toBeUndefined(); expect(mockAuthenticationAuditService.create).toHaveBeenCalledWith({ - actorId: "did:user:123", + actorId: "user-1", event: "logout" }); }); @@ -616,6 +700,7 @@ describe("EntityStorageAuthenticationService", () => { async () => { await userEntityStorage.set({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "stored-password-hash", @@ -632,6 +717,7 @@ describe("EntityStorageAuthenticationService", () => { await ContextIdStore.run({ [ContextIdKeys.Tenant]: TENANT_A }, async () => { await userEntityStorage.set({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "stored-password-hash", @@ -659,6 +745,7 @@ describe("EntityStorageAuthenticationService", () => { await ContextIdStore.run({ [ContextIdKeys.Tenant]: TENANT_A }, async () => { await userEntityStorage.set({ email: "user@example.com", + userId: "user-1", identity: "did:user:tenant-a", organization: "did:org:456", password: "stored-password-hash", @@ -669,6 +756,7 @@ describe("EntityStorageAuthenticationService", () => { await ContextIdStore.run({ [ContextIdKeys.Tenant]: TENANT_B }, async () => { await userEntityStorage.set({ email: "user@example.com", + userId: "user-b", identity: "did:user:tenant-b", organization: "did:org:456", password: "stored-password-hash", @@ -700,10 +788,11 @@ describe("EntityStorageAuthenticationService", () => { vi.spyOn(PasswordHelper, "updatePassword").mockResolvedValue(undefined); await ContextIdStore.run( - { [ContextIdKeys.Tenant]: TENANT_A, [ContextIdKeys.User]: "did:user:123" }, + { [ContextIdKeys.Tenant]: TENANT_A, [ContextIdKeys.Account]: "user-1" }, async () => { await userEntityStorage.set({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "stored-password-hash", @@ -728,6 +817,7 @@ describe("EntityStorageAuthenticationService", () => { await ContextIdStore.run({ [ContextIdKeys.Tenant]: TENANT_A }, async () => { await userEntityStorage.set({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "stored-password-hash", @@ -736,7 +826,7 @@ describe("EntityStorageAuthenticationService", () => { }); await ContextIdStore.run( - { [ContextIdKeys.Tenant]: TENANT_B, [ContextIdKeys.User]: "did:user:123" }, + { [ContextIdKeys.Tenant]: TENANT_B, [ContextIdKeys.Account]: "user-1" }, async () => { await expect(service.updatePassword("current-password", "new-password")).rejects.toThrow( NotFoundError @@ -749,6 +839,7 @@ describe("EntityStorageAuthenticationService", () => { await ContextIdStore.run({ [ContextIdKeys.Tenant]: TENANT_A }, async () => { await userEntityStorage.set({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "stored-password-hash", @@ -758,13 +849,13 @@ describe("EntityStorageAuthenticationService", () => { vi.spyOn(TokenHelper, "verify").mockImplementation( async (vaultConnector, nodeId, signingKeyName, token, verifyUser) => { - const verified = await verifyUser?.("did:user:123", "did:org:456", undefined, 0); + const verified = await verifyUser?.("user-1", "did:org:456", undefined, 0); if (!verified?.includes("user")) { throw new UnauthorizedError(TokenHelper.CLASS_NAME, "userNotVerified"); } return { header: { alg: "EdDSA" }, - payload: { sub: "did:user:123", org: "did:org:456", tid: TENANT_B } + payload: { sub: "user-1", org: "did:org:456", tid: TENANT_B } }; } ); @@ -784,6 +875,7 @@ describe("EntityStorageAuthenticationService", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "stored-password-hash", @@ -791,14 +883,14 @@ describe("EntityStorageAuthenticationService", () => { }); vi.spyOn(TokenHelper, "verify").mockImplementation( async (vaultConnector, nodeId, signingKeyName, token, verifyUser) => { - const verified = await verifyUser?.("did:user:123", "did:org:456", "tenant-a", 0); + const verified = await verifyUser?.("user-1", "did:org:456", "tenant-a", 0); if (!verified?.includes("tenant")) { throw new UnauthorizedError(TokenHelper.CLASS_NAME, "tenantNotVerified"); } return { header: { alg: "EdDSA" }, payload: { - sub: "did:user:123", + sub: "user-1", org: "did:org:456", tid: "tenant-a" } @@ -817,6 +909,7 @@ describe("EntityStorageAuthenticationService", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "stored-password-hash", @@ -841,14 +934,14 @@ describe("EntityStorageAuthenticationService", () => { vi.spyOn(TokenHelper, "verify").mockImplementation( async (vaultConnector, nodeId, signingKeyName, token, verifyUser) => { - const verified = await verifyUser?.("did:user:123", "did:org:456", "tenant-a", 0); + const verified = await verifyUser?.("user-1", "did:org:456", "tenant-a", 0); if (!verified?.includes("tenant")) { throw new UnauthorizedError(TokenHelper.CLASS_NAME, "tenantNotVerified"); } return { header: { alg: "EdDSA" }, payload: { - sub: "did:user:123", + sub: "user-1", org: "did:org:456", tid: "tenant-a" } @@ -868,6 +961,7 @@ describe("EntityStorageAuthenticationService", () => { }); vi.mocked(mockUserEntityStorage.get).mockResolvedValue({ email: "user@example.com", + userId: "user-1", identity: "did:user:123", organization: "did:org:456", password: "stored-password-hash", @@ -891,12 +985,12 @@ describe("EntityStorageAuthenticationService", () => { vi.spyOn(TokenHelper, "verify").mockImplementation( async (vaultConnector, nodeId, signingKeyName, token, verifyUser) => { - const verified = await verifyUser?.("did:user:123", "did:org:456", "tenant-a", 0); + const verified = await verifyUser?.("user-1", "did:org:456", "tenant-a", 0); expect(verified).toContain("tenant"); return { header: { alg: "EdDSA" }, payload: { - sub: "did:user:123", + sub: "user-1", org: "did:org:456", tid: "tenant-a" } @@ -917,11 +1011,180 @@ describe("EntityStorageAuthenticationService", () => { mockVaultConnector, "node-1", "auth-signing", - "did:user:123", + "user-1", "did:org:456", "tenant-a", 60, 0 ); }); + + describe("adoptIdentity", () => { + const mnemonic = "abandon abandon abandon abandon abandon abandon"; + const documentId = "did:iota:tst:abc"; + let identity: { + className: () => string; + identityCreate: ReturnType; + identityRemove: ReturnType; + }; + let user: AuthenticationUser; + + beforeEach(async () => { + identity = { + className: vi.fn().mockReturnValue("IdentityService"), + identityCreate: vi.fn().mockResolvedValue({ id: documentId, mnemonic }), + identityRemove: vi.fn().mockResolvedValue(undefined) + }; + user = { + email: "User@Example.com", + userId: "user-1", + organization: "did:org:node", + password: "stored-password-hash", + salt: "c2FsdA==", + passwordVersion: 0 + }; + vi.spyOn(ComponentFactory, "getIfExists").mockImplementation(componentName => { + if (componentName === "authentication-audit") { + return mockAuthenticationAuditService; + } + if (componentName === "identity-service") { + return identity; + } + return undefined; + }); + service = new EntityStorageAuthenticationService(); + vi.spyOn(ContextIdStore, "getContextIds").mockResolvedValue({ + [ContextIdKeys.Node]: "node-1", + [ContextIdKeys.Tenant]: "tenant-1", + [ContextIdKeys.Account]: "user-1" + }); + vi.mocked(mockUserEntityStorage.get).mockResolvedValue(user); + vi.spyOn(TokenHelper, "createToken").mockResolvedValue({ + token: "new-jwt-token", + expiry: 1722514341067 + }); + await service.start(); + }); + + it("should store the DID on the account and leave the token subject unchanged", async () => { + const result = await service.adoptIdentity(); + + expect(vi.mocked(identity.identityCreate)).toHaveBeenCalledWith(undefined, undefined, { + selfControlled: true + }); + expect(mockUserEntityStorage.get).toHaveBeenCalledWith("user-1", "userId"); + expect(mockUserEntityStorage.set).toHaveBeenCalledWith( + expect.objectContaining({ + email: "User@Example.com", + userId: "user-1", + identity: documentId, + organization: "did:org:node", + passwordVersion: 0 + }) + ); + expect(user.identity).toBeUndefined(); + expect(TokenHelper.createToken).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + expect(mockAuthenticationRateService.check).toHaveBeenCalledWith("identity-adopt", "user-1"); + expect(mockAuthenticationAuditService.create).toHaveBeenCalledWith({ + actorId: "user-1", + event: "account-updated", + data: { + previousIdentity: undefined, + userIdentity: documentId, + organizationIdentity: "did:org:node" + } + }); + expect( + JSON.stringify(vi.mocked(mockAuthenticationAuditService.create).mock.calls) + ).not.toContain(mnemonic); + }); + + it("should reject when the user already has a DID and leave the row unchanged", async () => { + user.identity = "did:iota:tst:existing"; + vi.spyOn(ContextIdStore, "getContextIds").mockResolvedValue({ + [ContextIdKeys.Node]: "node-1", + [ContextIdKeys.Account]: "user-1" + }); + + await expect(service.adoptIdentity()).rejects.toBeInstanceOf(ConflictError); + expect(vi.mocked(identity.identityCreate)).not.toHaveBeenCalled(); + expect(mockUserEntityStorage.set).not.toHaveBeenCalled(); + expect(user.identity).toBe("did:iota:tst:existing"); + }); + + it("should leave the stored identity unchanged when identity create throws", async () => { + vi.mocked(identity.identityCreate).mockRejectedValue(new Error("publish failed")); + + await expect(service.adoptIdentity()).rejects.toThrow("publish failed"); + expect(mockUserEntityStorage.set).not.toHaveBeenCalled(); + expect(TokenHelper.createToken).not.toHaveBeenCalled(); + expect(user.identity).toBeUndefined(); + expect(JSON.stringify(vi.mocked(identity.identityCreate).mock.results)).not.toContain( + "token" + ); + }); + + it("should remove the new DID with itself as controller when the save throws", async () => { + vi.mocked(mockUserEntityStorage.set).mockRejectedValue(new Error("save failed")); + + await expect(service.adoptIdentity()).rejects.toThrow("save failed"); + expect(vi.mocked(identity.identityRemove)).toHaveBeenCalledWith( + documentId, + { removeKeys: true, removeDocumentKey: true }, + documentId + ); + expect(TokenHelper.createToken).not.toHaveBeenCalled(); + expect(user.identity).toBeUndefined(); + }); + + it("should keep a recorded mnemonic out of the failure", async () => { + const created = { id: "not-a-did", mnemonic }; + vi.mocked(identity.identityCreate).mockResolvedValue(created); + + const rejection = await service.adoptIdentity().then( + () => "", + (error: Error) => `${error.message} ${JSON.stringify(error)}` + ); + expect(rejection).toContain("identityCreateFailed"); + expect(rejection).not.toContain(mnemonic); + expect(mockUserEntityStorage.set).not.toHaveBeenCalled(); + }); + + it("should refresh with the same account id after a DID is stored", async () => { + user.identity = documentId; + vi.spyOn(TokenHelper, "verify").mockImplementation( + async (vault, nodeId, signingKeyName, token, verifyUser) => { + const verified = await verifyUser?.("user-1", "did:org:node", "tenant-1", 0); + expect(verified).toContain("user"); + return { + header: { alg: "EdDSA" }, + payload: { + sub: "user-1", + org: "did:org:node", + tid: "tenant-1" + } + }; + } + ); + vi.spyOn(TokenHelper, "createToken").mockResolvedValue({ + token: "refreshed-token", + expiry: 987654321 + }); + + await service.refresh("existing-token"); + + expect(mockUserEntityStorage.get).toHaveBeenCalledWith("user-1", "userId"); + expect(TokenHelper.createToken).toHaveBeenCalledWith( + mockVaultConnector, + "node-1", + "auth-signing", + "user-1", + "did:org:node", + "tenant-1", + 60, + 0 + ); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 042d76f..1180cd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,7 +253,7 @@ importers: version: 0.20.0-next.4 '@3sixty/nameof-vitest-plugin': specifier: next - version: 0.20.0-next.4(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + version: 0.20.0-next.4(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) '@3sixty/validate-locales': specifier: next version: 0.20.0-next.4 @@ -283,7 +283,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.5 - version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) packages/api-auth-entity-storage-rest-client: dependencies: @@ -311,7 +311,7 @@ importers: version: 0.20.0-next.4 '@3sixty/nameof-vitest-plugin': specifier: next - version: 0.20.0-next.4(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + version: 0.20.0-next.4(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) '@3sixty/validate-locales': specifier: next version: 0.20.0-next.4 @@ -341,7 +341,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.5 - version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) packages/api-auth-entity-storage-service: dependencies: @@ -372,6 +372,9 @@ importers: '@3sixty/entity-storage-models': specifier: next version: 0.20.0-next.2 + '@3sixty/identity-models': + specifier: next + version: 0.20.0-next.3 '@3sixty/nameof': specifier: next version: 0.20.0-next.4 @@ -390,7 +393,7 @@ importers: version: 0.20.0-next.4 '@3sixty/nameof-vitest-plugin': specifier: next - version: 0.20.0-next.4(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + version: 0.20.0-next.4(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) '@3sixty/ts-to-openapi': specifier: next version: 0.20.0-next.6 @@ -423,7 +426,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.5 - version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) packages/api-auth-models: dependencies: @@ -436,7 +439,7 @@ importers: version: 0.20.0-next.4 '@3sixty/nameof-vitest-plugin': specifier: next - version: 0.20.0-next.4(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + version: 0.20.0-next.4(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) '@3sixty/validate-locales': specifier: next version: 0.20.0-next.4 @@ -466,7 +469,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.5 - version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) packages/api-auth-rest-client: dependencies: @@ -494,7 +497,7 @@ importers: version: 0.20.0-next.4 '@3sixty/nameof-vitest-plugin': specifier: next - version: 0.20.0-next.4(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + version: 0.20.0-next.4(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) '@3sixty/validate-locales': specifier: next version: 0.20.0-next.4 @@ -524,7 +527,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.5 - version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) packages/api-auth-service: dependencies: @@ -570,7 +573,7 @@ importers: version: 0.20.0-next.4 '@3sixty/nameof-vitest-plugin': specifier: next - version: 0.20.0-next.4(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + version: 0.20.0-next.4(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) '@3sixty/ts-to-openapi': specifier: next version: 0.20.0-next.6 @@ -603,7 +606,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.5 - version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) packages: @@ -639,6 +642,14 @@ packages: resolution: {integrity: sha512-I4itGN4AtPwng6RK8k3hR11mVtJb4g3jOBLsxfsNOku2WDzsXdD+NT9kZ9sBtC09Gal70/4s6xV/5t4fK78cGw==} engines: {bun: '>=1.4.4', node: '>=24.0.0'} + '@3sixty/data-core@0.20.0-next.4': + resolution: {integrity: sha512-pXwWiwg6puOdrVAJoA/ZEN0JeOYKP9kzog75cjgPsBf391sRYdDwpS6eYXuE+uOkaxv9dOPsBvSONoIZjhq5hQ==} + engines: {bun: '>=1.4.4', node: '>=24.0.0'} + + '@3sixty/data-json-ld@0.20.0-next.4': + resolution: {integrity: sha512-xUQU4EhlkIShuv59KuZfm2yMgRIBHWO6kc+grENZmZTt41rL0sAnhGy8fxsqRWW6DYmpsPZ5exmqUVHKoNtySA==} + engines: {bun: '>=1.4.4', node: '>=24.0.0'} + '@3sixty/entity-storage-connector-memory@0.20.0-next.2': resolution: {integrity: sha512-LDPSydkp9pRXlSRGA0zcwDiUg2P6sQTuJjnpOVqc5ZGtezzx4wtahQMUKpeMwlt5+Lr15iiwfxe+tb/dXtVu/Q==} engines: {bun: '>=1.4.4', node: '>=24.0.0'} @@ -651,6 +662,10 @@ packages: resolution: {integrity: sha512-GUgvE4EA3SOIrHWz7btls7jAI8FoVL/N0wlqP/S/liCtcDzDBN8EkJjzeQ/3LYEr209x7F/RcP5Jn0UNF/D79Q==} engines: {bun: '>=1.4.4', node: '>=24.0.0'} + '@3sixty/identity-models@0.20.0-next.3': + resolution: {integrity: sha512-eTipJ7ldva0JWBBnbpptaktzbn4q24UZtAqXSY0qvLY+PEggnOBS7RGxA6J8FO5Sj1OZ7uYY3bT3FUbCi4Hr4w==} + engines: {bun: '>=1.4.4', node: '>=24.0.0'} + '@3sixty/logging-models@0.20.0-next.2': resolution: {integrity: sha512-MLUhNB6oojDYu/38fDs6M+XKkpdkeLblglylOfuvd30DSz1nJ8UBQ/HY/Eu4dNClXqHlYSLu8siyzwpgpL+6UA==} engines: {bun: '>=1.4.4', node: '>=24.0.0'} @@ -668,6 +683,10 @@ packages: resolution: {integrity: sha512-vERokiuGRph0oESD71WqFqqiTua4vSddovkUQSPt3WrMC5GRRvi1nJCnkDyLKQsCiKnHXwBg7ebTjPGluVjrKw==} engines: {bun: '>=1.4.4', node: '>=24.0.0'} + '@3sixty/standards-w3c-did@0.20.0-next.3': + resolution: {integrity: sha512-/snKyKTpq+DJdr4Q+noNpH2aRurJnwzt06Z1hf1Zf6gqfD1pDGajKIgRuvSUsg4CDF8pRMNY4imR8hhCXK9U9g==} + engines: {bun: '>=1.4.4', node: '>=24.0.0'} + '@3sixty/telemetry-models@0.20.0-next.2': resolution: {integrity: sha512-l1vx6/Hic4PrNiKlmQzuTjM2n/kXg6JXyPnXbaiLVrPVAwOqLhAKuglBFrkUoc/QdQ04yeO7oSqBQdsRr4YEfg==} engines: {bun: '>=1.4.4', node: '>=24.0.0'} @@ -702,21 +721,21 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + '@babel/helper-string-parser@7.29.11': + resolution: {integrity: sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==} engines: {node: '>=6.9.0'} '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.9': - resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + '@babel/parser@7.29.11': + resolution: {integrity: sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/types@7.29.8': - resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + '@babel/types@7.29.11': + resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@1.0.2': @@ -864,8 +883,8 @@ packages: '@cspell/dict-companies@3.2.13': resolution: {integrity: sha512-mBJeYzCkiiWgNQxKg7zyQOpFPwcXO4IAzXGbOEFfcQ4Q0G5Fa7Kqd8z+gvnvSK7NPWKJaFmiApC6I0cka02qmQ==} - '@cspell/dict-cpp@7.1.3': - resolution: {integrity: sha512-2d2MSh+C+xzzdqjLwtt40TdXWDR/pK0q1Q9L5zC+1EPSCIZxlN7CHbhlXWpl0JqUSdy2xaWaWucwnoGznMTneA==} + '@cspell/dict-cpp@7.2.0': + resolution: {integrity: sha512-hxz+OyQKV96/Bk2JYGsDuSOheYA5gd8KybXnoZMMu/mhZPnwhPi+qiS0Kjc0gLNbXjJ6wbBuXIQWi1UWpQBU/g==} '@cspell/dict-cryptocurrencies@5.0.5': resolution: {integrity: sha512-R68hYYF/rtlE6T/dsObStzN5QZw+0aQBinAXuWCVqwdS7YZo0X33vGMfChkHaiCo3Z2+bkegqHlqxZF4TD3rUA==} @@ -897,11 +916,11 @@ packages: '@cspell/dict-en-common-misspellings@2.2.5': resolution: {integrity: sha512-c1bj+HXT8fafxrBT/nmVLwr7ILVomgVSGlWsb2wjJYanqvLJHXkCVmr0sM97F9FJeWQFIgwrRi5MWSYbNQAQ6A==} - '@cspell/dict-en-gb-mit@3.1.30': - resolution: {integrity: sha512-wmsPqPrO+GRfD8iA2jSKzRdEVd46ZbnDttRWM8RC4a8DkeiIanTtIgqX5V23jJibSgpsRVAtY+ktTw2nOK/Yvw==} + '@cspell/dict-en-gb-mit@3.1.32': + resolution: {integrity: sha512-bEASyTAzXLowmFWrZIJjeAeCpY9g3jcYDO3tVyFDL/UaAuz0UvCVgOEyg+Ym7bclijqvXmj0xXOD/cW2XWG8iw==} - '@cspell/dict-en_us@4.4.41': - resolution: {integrity: sha512-PsfGqN/dSSPpaSl1vKaPI4DHAmgbFITON398YUEvoduDM1mWXnDOL+qBQ/ZjpWs5+aaShPq5c1vpJg8fuQFOQw==} + '@cspell/dict-en_us@4.4.43': + resolution: {integrity: sha512-RK0zfFXSPi0G/zmV9YgUhS2xw9eTV/8DtQ+ouLIiRZ8ejWRt2zhAonu5di+qmIhzhQ62fg7d3tMz3WwRy82yJA==} '@cspell/dict-filetypes@3.0.18': resolution: {integrity: sha512-yU7RKD/x1IWmDLzWeiItMwgV+6bUcU/af23uS0+uGiFUbsY1qWV/D4rxlAAO6Z7no3J2z8aZOkYIOvUrJq0Rcw==} @@ -977,8 +996,8 @@ packages: '@cspell/dict-node@5.0.9': resolution: {integrity: sha512-hO+ga+uYZ/WA4OtiMEyKt5rDUlUyu3nXMf8KVEeqq2msYvAPdldKBGH7lGONg6R/rPhv53Rb+0Y1SLdoK1+7wQ==} - '@cspell/dict-npm@5.2.54': - resolution: {integrity: sha512-7jQPRZL1XnZ0HPk5iYAq5WpJ5dpgKHRDBl9aB6uQQBJdo90mCF4bjPGSVl2HrWgx+IXlafiyx/QNO2XII/hgYg==} + '@cspell/dict-npm@5.2.55': + resolution: {integrity: sha512-5xD2h3kn9ZRVbA6JFfO4VQu8VXwXsJQf7mpn099LgfaL66JnXE+wzR5dysipsren3Apdb3HlUzVqRTtnQMoVGQ==} '@cspell/dict-php@4.1.3': resolution: {integrity: sha512-41oKzl7rLVrrVZA/Gs0ESnP6KliEX7c+IwaaNmOL3PAoHasFDVEmIFeENIzuDdM12UcHT+MlLkAm3Qw/lS/ERA==} @@ -1051,6 +1070,10 @@ packages: resolution: {integrity: sha512-8NvDv8h3eaxejAvD+crMsSEgGq6LiunfS6iD2TBAcDqskNsIpN43AMHNNRf7T7Fd/3Y413nQDBH4RmsHcNsiqg==} engines: {node: '>=22.18.0'} + '@digitalbazaar/http-client@4.4.0': + resolution: {integrity: sha512-ODhCGmElUPmR3IR+KZmBNkRAFyjJ01rxvk2E+/qQ2h2EGPJH5k6bz3N24ympGc5+i4YCGk/ipIpmkwc0+iSmRg==} + engines: {node: '>=18.0'} + '@dual-bundle/import-meta-resolve@4.2.1': resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} @@ -1188,12 +1211,12 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@napi-rs/wasm-runtime@1.2.5': - resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} + '@napi-rs/wasm-runtime@1.2.6': + resolution: {integrity: sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: - '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 - '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/core': ^1.7.1 || 2.0.0-beta.2 + '@emnapi/runtime': ^1.7.1 || 2.0.0-beta.2 '@noble/ciphers@2.4.0': resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} @@ -1271,8 +1294,8 @@ packages: '@octokit/types@13.10.0': resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} - '@oxc-project/types@0.154.0': - resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} + '@oxc-project/types@0.155.0': + resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} '@pkgr/core@0.3.6': resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} @@ -1282,98 +1305,98 @@ packages: resolution: {integrity: sha512-4EDEmvxWtgsCnnVeBvtFIFZtUhPPt1+bA9JrSwU4Sa//6oKtzCSlGGXYJr44OD9aGISymbieJ4mCKHUygUDU+g==} engines: {node: '>=18'} - '@rolldown/binding-android-arm-eabi@1.2.14': - resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==} + '@rolldown/binding-android-arm-eabi@1.2.15': + resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.14': - resolution: {integrity: sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==} + '@rolldown/binding-android-arm64@1.2.15': + resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.14': - resolution: {integrity: sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==} + '@rolldown/binding-darwin-arm64@1.2.15': + resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.14': - resolution: {integrity: sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==} + '@rolldown/binding-darwin-x64@1.2.15': + resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.14': - resolution: {integrity: sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==} + '@rolldown/binding-freebsd-x64@1.2.15': + resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.14': - resolution: {integrity: sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': + resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.14': - resolution: {integrity: sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==} + '@rolldown/binding-linux-arm64-gnu@1.2.15': + resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.14': - resolution: {integrity: sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==} + '@rolldown/binding-linux-arm64-musl@1.2.15': + resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.14': - resolution: {integrity: sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==} + '@rolldown/binding-linux-ppc64-gnu@1.2.15': + resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.14': - resolution: {integrity: sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==} + '@rolldown/binding-linux-s390x-gnu@1.2.15': + resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.14': - resolution: {integrity: sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==} + '@rolldown/binding-linux-x64-gnu@1.2.15': + resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.14': - resolution: {integrity: sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==} + '@rolldown/binding-linux-x64-musl@1.2.15': + resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.14': - resolution: {integrity: sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==} + '@rolldown/binding-openharmony-arm64@1.2.15': + resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.14': - resolution: {integrity: sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==} + '@rolldown/binding-win32-arm64-msvc@1.2.15': + resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.14': - resolution: {integrity: sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==} + '@rolldown/binding-win32-x64-msvc@1.2.15': + resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1459,6 +1482,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonld@1.5.15': + resolution: {integrity: sha512-PlAFPZjL+AuGYmwlqwKEL0IMP8M8RexH0NIPGfCVWSQ041H2rR/8OlyZSD7KsCVoN8vCfWdtWDBxX8yBVP+xow==} + '@types/katex@0.16.8': resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} @@ -1837,6 +1863,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -1870,8 +1904,8 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - argparse@3.0.3: - resolution: {integrity: sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==} + argparse@3.0.4: + resolution: {integrity: sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==} argue-cli@3.2.0: resolution: {integrity: sha512-VipTB0gXgGIFO2Rg9yEVN5wLt2AurJcZqDbgmYSwPwsykhLrQhs240/bfceev4w68lI8JshoOJIk9uW7tFzROw==} @@ -1904,8 +1938,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.30: - resolution: {integrity: sha512-YJs6Pz17RMNv8HoyKREtCY38E3BV1nPkWVmIb+XN1uHC1Vqt3KZCH/uJH9ZKRoiKb/icZ6tvehyee6RZNbFfRA==} + baseline-browser-mapping@2.11.32: + resolution: {integrity: sha512-UvuxFU9P3hM29LrQdwu0cD6w6/+SFr+UlB9s/knI7+O2rK9xFxKUwSDWxGdbbqQ9/V7BJ5s0NI5nQAE4uJp3gQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -1954,8 +1988,12 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - caniuse-lite@1.0.30001816: - resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==} + caniuse-lite@1.0.30001819: + resolution: {integrity: sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==} + + canonicalize@2.1.0: + resolution: {integrity: sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==} + hasBin: true chai@6.3.0: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} @@ -2204,8 +2242,8 @@ packages: engines: {node: '>=12'} hasBin: true - electron-to-chromium@1.5.459: - resolution: {integrity: sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==} + electron-to-chromium@1.5.464: + resolution: {integrity: sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2246,8 +2284,8 @@ packages: es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} - es-toolkit@1.52.0: - resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + es-toolkit@1.53.0: + resolution: {integrity: sha512-3+AYXp9fmJ/UhHBMYj2/U+RaUouMD90SzoItyDOB2Za7W6dB7fQApFGnZPl2MF6K8/Tv1koh1JyirSx/kju6Pw==} escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} @@ -2588,8 +2626,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.13: - resolution: {integrity: sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==} + ignore@7.0.14: + resolution: {integrity: sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==} engines: {node: '>= 4'} import-fresh@3.3.1: @@ -2740,6 +2778,10 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonld@9.0.0: + resolution: {integrity: sha512-pjMIdkXfC1T2wrX9B9i2uXhGdyCmgec3qgMht+TDj+S0qX3bjWMQUfL7NeqEhuRTi8G5ESzmL9uGlST7nzSEWg==} + engines: {node: '>=18'} + jsonpath-plus@10.4.0: resolution: {integrity: sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==} engines: {node: '>=18.0.0'} @@ -2760,6 +2802,10 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + ky@1.14.3: + resolution: {integrity: sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==} + engines: {node: '>=18'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2930,6 +2976,9 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + micromark-extension-directive@4.0.0: resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==} @@ -3002,6 +3051,9 @@ packages: micromark-util-types@2.0.2: resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} @@ -3050,8 +3102,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - napi-postinstall@0.3.5: - resolution: {integrity: sha512-NFMjXXkyodBJnRrM0A6qxFO1OLUbrYLDhP1ZLetlXnyx41gKOAmVyROibq85j7I8dda4aZMs9L7kmRvvjNf7Cg==} + napi-postinstall@0.3.7: + resolution: {integrity: sha512-jyU4cU4kVAmy/3qjP+DcoQTNXfINHc+dFw54gGqWC8nu0mpV0kOnk7kff+J+Q18SbX0k3Np8Rm7yFjd3phatNQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} hasBin: true @@ -3064,8 +3116,8 @@ packages: node-html-parser@6.1.13: resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==} - node-releases@2.0.59: - resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==} + node-releases@2.0.60: + resolution: {integrity: sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==} engines: {node: '>=18'} nodemon@3.1.14: @@ -3228,6 +3280,10 @@ packages: resolution: {integrity: sha512-Y3NoRtprEEZQD8RfxMCfS0ZTqc4e+i18OrXEXAvpM6TfC/3y+0L5rNbZiSnbBBEkDfFzbpd8o+cE8q3/anjMGA==} engines: {node: '>=22'} + rdf-canonize@5.0.0: + resolution: {integrity: sha512-g8OUrgMXAR9ys/ZuJVfBr05sPPoMA7nHIVs8VEvg9QwM5W4GR2qSFEEHjsyHF1eWlBaf8Ev40WNjQFQ+nJTO3w==} + engines: {node: '>=18'} + read-pkg-up@7.0.1: resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} engines: {node: '>=8'} @@ -3300,8 +3356,8 @@ packages: engines: {node: 20 || >=22} hasBin: true - rolldown@1.2.14: - resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==} + rolldown@1.2.15: + resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -3324,6 +3380,9 @@ packages: engines: {node: '>=10'} hasBin: true + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3562,6 +3621,10 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + undici@6.29.1: + resolution: {integrity: sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==} + engines: {node: '>=18.17'} + unicorn-magic@0.4.1: resolution: {integrity: sha512-lzlXPoVB0Uy/FvTaUgX39RbixYiSKoc3JSoSf01+0c2B6FR3qdYIPN7Qjo/AV7n6sqLCk0509cT3LRj1oSZ1+A==} engines: {node: '>=20'} @@ -3597,13 +3660,13 @@ packages: resolution: {integrity: sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==} engines: {node: '>=18.12.0'} - vite@8.3.4: - resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + vite@8.3.5: + resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.7.1 + '@vitejs/devtools': ^0.7.1 || ^0.8.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -3681,8 +3744,8 @@ packages: jsdom: optional: true - vscode-languageserver-textdocument@1.0.15: - resolution: {integrity: sha512-Gsfr9S8LU3S6885cS+NaleasJ0n3V5y/SOTqHCifQiXWFw3G9w9/YItDfwHrlCz+dSZWWZ8ilYVMcJe7Sz7Sxw==} + vscode-languageserver-textdocument@1.0.16: + resolution: {integrity: sha512-qeNEuOi9a3qkqRf7Gfez2WL2VwlBTLCNekHBGO9kxPVUp9XuY15yfTJ9jwvOwKI0I2MhUUfZq95NSp+LK1CLQg==} vscode-uri@3.2.0: resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} @@ -3841,6 +3904,25 @@ snapshots: '@scure/bip39': 2.4.0 micro-key-producer: 0.10.2 + '@3sixty/data-core@0.20.0-next.4': + dependencies: + '@3sixty/core': 0.20.0-next.4 + '@3sixty/crypto': 0.20.0-next.4 + '@3sixty/entity': 0.20.0-next.4 + '@3sixty/nameof': 0.20.0-next.4 + '@3sixty/web': 0.20.0-next.4 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + + '@3sixty/data-json-ld@0.20.0-next.4': + dependencies: + '@3sixty/core': 0.20.0-next.4 + '@3sixty/data-core': 0.20.0-next.4 + '@3sixty/nameof': 0.20.0-next.4 + '@3sixty/web': 0.20.0-next.4 + '@types/jsonld': 1.5.15 + jsonld: 9.0.0 + '@3sixty/entity-storage-connector-memory@0.20.0-next.2': dependencies: '@3sixty/api-models': 0.20.0-next.2 @@ -3867,6 +3949,16 @@ snapshots: reflect-metadata: 0.2.2 tslib: 2.8.1 + '@3sixty/identity-models@0.20.0-next.3': + dependencies: + '@3sixty/context': 0.20.0-next.4 + '@3sixty/core': 0.20.0-next.4 + '@3sixty/data-json-ld': 0.20.0-next.4 + '@3sixty/nameof': 0.20.0-next.4 + '@3sixty/standards-w3c-did': 0.20.0-next.3 + '@3sixty/telemetry-models': 0.20.0-next.2 + '@3sixty/web': 0.20.0-next.4 + '@3sixty/logging-models@0.20.0-next.2': dependencies: '@3sixty/core': 0.20.0-next.4 @@ -3878,10 +3970,10 @@ snapshots: typescript: 6.0.3 typescript-native: typescript@7.1.0-beta - '@3sixty/nameof-vitest-plugin@0.20.0-next.4(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0))': + '@3sixty/nameof-vitest-plugin@0.20.0-next.4(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0))': dependencies: '@3sixty/nameof-transformer': 0.20.0-next.4 - vitest: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + vitest: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) transitivePeerDependencies: - '@edge-runtime/vm' - '@opentelemetry/api' @@ -3899,6 +3991,15 @@ snapshots: '@3sixty/nameof@0.20.0-next.4': {} + '@3sixty/standards-w3c-did@0.20.0-next.3': + dependencies: + '@3sixty/core': 0.20.0-next.4 + '@3sixty/crypto': 0.20.0-next.4 + '@3sixty/data-core': 0.20.0-next.4 + '@3sixty/data-json-ld': 0.20.0-next.4 + '@3sixty/nameof': 0.20.0-next.4 + '@3sixty/web': 0.20.0-next.4 + '@3sixty/telemetry-models@0.20.0-next.2': dependencies: '@3sixty/core': 0.20.0-next.4 @@ -3954,17 +4055,17 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@7.29.11': {} '@babel/helper-validator-identifier@7.29.7': {} - '@babel/parser@7.29.9': + '@babel/parser@7.29.11': dependencies: - '@babel/types': 7.29.8 + '@babel/types': 7.29.11 - '@babel/types@7.29.8': + '@babel/types@7.29.11': dependencies: - '@babel/helper-string-parser': 7.29.7 + '@babel/helper-string-parser': 7.29.11 '@babel/helper-validator-identifier': 7.29.7 '@bcoe/v8-coverage@1.0.2': {} @@ -4009,7 +4110,7 @@ snapshots: '@commitlint/ensure@21.2.4': dependencies: '@commitlint/types': 21.2.3 - es-toolkit: 1.52.0 + es-toolkit: 1.53.0 '@commitlint/execute-rule@21.0.1': {} @@ -4037,7 +4138,7 @@ snapshots: '@commitlint/resolve-extends': 21.2.3 '@commitlint/types': 21.2.3 cosmiconfig: 9.0.2(typescript@6.0.3) - es-toolkit: 1.52.0 + es-toolkit: 1.53.0 is-plain-obj: 4.1.0 jiti: 2.7.0 picocolors: 1.1.1 @@ -4066,7 +4167,7 @@ snapshots: dependencies: '@commitlint/config-validator': 21.2.3 '@commitlint/types': 21.2.3 - es-toolkit: 1.52.0 + es-toolkit: 1.53.0 global-directory: 5.0.0 resolve-from: 5.0.0 @@ -4110,7 +4211,7 @@ snapshots: '@cspell/dict-aws': 4.0.17 '@cspell/dict-bash': 4.2.3 '@cspell/dict-companies': 3.2.13 - '@cspell/dict-cpp': 7.1.3 + '@cspell/dict-cpp': 7.2.0 '@cspell/dict-cryptocurrencies': 5.0.5 '@cspell/dict-csharp': 4.0.8 '@cspell/dict-css': 4.1.2 @@ -4121,8 +4222,8 @@ snapshots: '@cspell/dict-dotnet': 5.0.15 '@cspell/dict-elixir': 4.0.8 '@cspell/dict-en-common-misspellings': 2.2.5 - '@cspell/dict-en-gb-mit': 3.1.30 - '@cspell/dict-en_us': 4.4.41 + '@cspell/dict-en-gb-mit': 3.1.32 + '@cspell/dict-en_us': 4.4.43 '@cspell/dict-filetypes': 3.0.18 '@cspell/dict-flutter': 1.1.1 '@cspell/dict-fonts': 4.0.6 @@ -4146,7 +4247,7 @@ snapshots: '@cspell/dict-markdown': 2.0.18(@cspell/dict-css@4.1.2)(@cspell/dict-html-symbol-entities@4.0.5)(@cspell/dict-html@4.0.16)(@cspell/dict-typescript@3.2.3) '@cspell/dict-monkeyc': 1.1.2 '@cspell/dict-node': 5.0.9 - '@cspell/dict-npm': 5.2.54 + '@cspell/dict-npm': 5.2.55 '@cspell/dict-php': 4.1.3 '@cspell/dict-powershell': 5.0.17 '@cspell/dict-public-licenses': 2.0.17 @@ -4197,7 +4298,7 @@ snapshots: '@cspell/dict-companies@3.2.13': {} - '@cspell/dict-cpp@7.1.3': {} + '@cspell/dict-cpp@7.2.0': {} '@cspell/dict-cryptocurrencies@5.0.5': {} @@ -4219,9 +4320,9 @@ snapshots: '@cspell/dict-en-common-misspellings@2.2.5': {} - '@cspell/dict-en-gb-mit@3.1.30': {} + '@cspell/dict-en-gb-mit@3.1.32': {} - '@cspell/dict-en_us@4.4.41': {} + '@cspell/dict-en_us@4.4.43': {} '@cspell/dict-filetypes@3.0.18': {} @@ -4274,7 +4375,7 @@ snapshots: '@cspell/dict-node@5.0.9': {} - '@cspell/dict-npm@5.2.54': {} + '@cspell/dict-npm@5.2.55': {} '@cspell/dict-php@4.1.3': {} @@ -4325,6 +4426,11 @@ snapshots: '@cspell/url@10.4.0': {} + '@digitalbazaar/http-client@4.4.0': + dependencies: + ky: 1.14.3 + undici: 6.29.1 + '@dual-bundle/import-meta-resolve@4.2.1': {} '@emnapi/core@1.10.0': @@ -4470,7 +4576,7 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.2.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 @@ -4560,7 +4666,7 @@ snapshots: dependencies: '@octokit/openapi-types': 24.2.0 - '@oxc-project/types@0.154.0': {} + '@oxc-project/types@0.155.0': {} '@pkgr/core@0.3.6': {} @@ -4568,49 +4674,49 @@ snapshots: dependencies: tinyexec: 1.3.1 - '@rolldown/binding-android-arm-eabi@1.2.14': + '@rolldown/binding-android-arm-eabi@1.2.15': optional: true - '@rolldown/binding-android-arm64@1.2.14': + '@rolldown/binding-android-arm64@1.2.15': optional: true - '@rolldown/binding-darwin-arm64@1.2.14': + '@rolldown/binding-darwin-arm64@1.2.15': optional: true - '@rolldown/binding-darwin-x64@1.2.14': + '@rolldown/binding-darwin-x64@1.2.15': optional: true - '@rolldown/binding-freebsd-x64@1.2.14': + '@rolldown/binding-freebsd-x64@1.2.15': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.14': + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.14': + '@rolldown/binding-linux-arm64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.14': + '@rolldown/binding-linux-arm64-musl@1.2.15': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.14': + '@rolldown/binding-linux-ppc64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.14': + '@rolldown/binding-linux-s390x-gnu@1.2.15': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.14': + '@rolldown/binding-linux-x64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-x64-musl@1.2.14': + '@rolldown/binding-linux-x64-musl@1.2.15': optional: true - '@rolldown/binding-openharmony-arm64@1.2.14': + '@rolldown/binding-openharmony-arm64@1.2.15': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.14': + '@rolldown/binding-win32-arm64-msvc@1.2.15': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.14': + '@rolldown/binding-win32-x64-msvc@1.2.15': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -4699,6 +4805,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonld@1.5.15': {} + '@types/katex@0.16.8': {} '@types/minimist@1.2.5': {} @@ -4726,7 +4834,7 @@ snapshots: '@typescript-eslint/utils': 8.72.0(eslint@10.13.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.72.0 eslint: 10.13.0(jiti@2.7.0)(supports-color@10.2.2) - ignore: 7.0.13 + ignore: 7.0.14 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 @@ -4929,7 +5037,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.2.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': @@ -4951,7 +5059,7 @@ snapshots: obug: 3.0.0 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + vitest: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) '@vitest/istanbul-lib-coverage@1.0.2': {} @@ -4959,14 +5067,14 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 1.0.2 - '@vitest/mocker@5.0.5(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0))': + '@vitest/mocker@5.0.5(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.5 estree-walker: 3.0.3 magic-string: 1.4.3 optionalDependencies: - vite: 8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1) + vite: 8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1) '@vitest/spy@5.0.5': {} @@ -4980,6 +5088,10 @@ snapshots: agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -5013,7 +5125,7 @@ snapshots: argparse@2.0.1: {} - argparse@3.0.3: {} + argparse@3.0.4: {} argue-cli@3.2.0: {} @@ -5039,7 +5151,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.30: {} + baseline-browser-mapping@2.11.32: {} before-after-hook@2.2.3: {} @@ -5062,10 +5174,10 @@ snapshots: browserslist@4.29.4: dependencies: - baseline-browser-mapping: 2.11.30 - caniuse-lite: 1.0.30001816 - electron-to-chromium: 1.5.459 - node-releases: 2.0.59 + baseline-browser-mapping: 2.11.32 + caniuse-lite: 1.0.30001819 + electron-to-chromium: 1.5.464 + node-releases: 2.0.60 update-browserslist-db: 1.3.4(browserslist@4.29.4) builtin-modules@5.4.0: {} @@ -5088,7 +5200,9 @@ snapshots: camelcase@5.3.1: {} - caniuse-lite@1.0.30001816: {} + caniuse-lite@1.0.30001819: {} + + canonicalize@2.1.0: {} chai@6.3.0: {} @@ -5281,7 +5395,7 @@ snapshots: gensequence: 8.0.8 import-fresh: 4.0.1 resolve-from: 5.0.0 - vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-textdocument: 1.0.16 vscode-uri: 3.2.0 xdg-basedir: 5.1.0 @@ -5389,7 +5503,7 @@ snapshots: dotenv@18.0.8: {} - electron-to-chromium@1.5.459: {} + electron-to-chromium@1.5.464: {} emoji-regex@10.6.0: {} @@ -5427,7 +5541,7 @@ snapshots: es-module-lexer@2.3.2: {} - es-toolkit@1.52.0: {} + es-toolkit@1.53.0: {} escalade@3.2.0: {} @@ -5725,7 +5839,7 @@ snapshots: dependencies: '@sindresorhus/merge-streams': 4.0.0 fast-glob: 3.3.3 - ignore: 7.0.13 + ignore: 7.0.14 is-path-inside: 4.0.0 micromatch: 4.0.8 slash: 5.1.0 @@ -5790,7 +5904,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.13: {} + ignore@7.0.14: {} import-fresh@3.3.1: dependencies: @@ -5897,6 +6011,13 @@ snapshots: jsonc-parser@3.3.1: {} + jsonld@9.0.0: + dependencies: + '@digitalbazaar/http-client': 4.4.0 + canonicalize: 2.1.0 + lru-cache: 6.0.0 + rdf-canonize: 5.0.0 + jsonpath-plus@10.4.0: dependencies: '@jsep-plugin/assignment': 1.4.0(jsep@1.6.0) @@ -5915,6 +6036,8 @@ snapshots: kind-of@6.0.3: {} + ky@1.14.3: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -6003,8 +6126,8 @@ snapshots: magicast@0.5.5: dependencies: - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 + '@babel/parser': 7.29.11 + '@babel/types': 7.29.11 source-map-js: 1.2.2 map-obj@1.0.1: {} @@ -6022,7 +6145,7 @@ snapshots: markdown-it@15.0.1: dependencies: - argparse: 3.0.3 + argparse: 3.0.4 entities: 8.1.0 linkify-it: 6.1.0 mdurl: 2.1.0 @@ -6112,6 +6235,26 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-core-commonmark@2.0.4: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + micromark-extension-directive@4.0.0: dependencies: devlop: 1.1.0 @@ -6162,38 +6305,38 @@ snapshots: dependencies: micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-factory-label@2.0.1: dependencies: devlop: 1.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-factory-space@2.1.0: dependencies: micromark-util-character: 2.1.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-factory-title@2.0.1: dependencies: micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-factory-whitespace@2.0.1: dependencies: micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-chunked@2.0.1: dependencies: @@ -6203,12 +6346,12 @@ snapshots: dependencies: micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-combine-extensions@2.0.1: dependencies: micromark-util-chunked: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-decode-numeric-character-reference@2.0.2: dependencies: @@ -6216,7 +6359,7 @@ snapshots: micromark-util-edit-map@1.0.0: dependencies: - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-encode@2.0.1: {} @@ -6228,7 +6371,7 @@ snapshots: micromark-util-resolve-all@2.0.1: dependencies: - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-sanitize-uri@2.0.1: dependencies: @@ -6241,12 +6384,14 @@ snapshots: devlop: 1.1.0 micromark-util-chunked: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-symbol@2.0.1: {} micromark-util-types@2.0.2: {} + micromark-util-types@2.0.3: {} + micromark@4.0.2(supports-color@10.2.2): dependencies: '@types/debug': 4.1.13 @@ -6275,7 +6420,7 @@ snapshots: debug: 4.4.3(supports-color@10.2.2) decode-named-character-reference: 1.3.0 devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 + micromark-core-commonmark: 2.0.4 micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-chunked: 2.0.1 @@ -6288,7 +6433,7 @@ snapshots: micromark-util-sanitize-uri: 2.0.1 micromark-util-subtokenize: 2.1.0 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 transitivePeerDependencies: - supports-color @@ -6325,7 +6470,7 @@ snapshots: nanoid@3.3.20: {} - napi-postinstall@0.3.5: {} + napi-postinstall@0.3.7: {} natural-compare@1.4.0: {} @@ -6336,7 +6481,7 @@ snapshots: css-select: 5.2.2 he: 1.2.0 - node-releases@2.0.59: {} + node-releases@2.0.60: {} nodemon@3.1.14: dependencies: @@ -6499,6 +6644,10 @@ snapshots: quote-js-string@0.1.0: {} + rdf-canonize@5.0.0: + dependencies: + setimmediate: 1.0.5 + read-pkg-up@7.0.1: dependencies: find-up: 4.1.0 @@ -6593,26 +6742,26 @@ snapshots: glob: 13.0.6 package-json-from-dist: 1.0.1 - rolldown@1.2.14: + rolldown@1.2.15: dependencies: - '@oxc-project/types': 0.154.0 + '@oxc-project/types': 0.155.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.14 - '@rolldown/binding-android-arm64': 1.2.14 - '@rolldown/binding-darwin-arm64': 1.2.14 - '@rolldown/binding-darwin-x64': 1.2.14 - '@rolldown/binding-freebsd-x64': 1.2.14 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.14 - '@rolldown/binding-linux-arm64-gnu': 1.2.14 - '@rolldown/binding-linux-arm64-musl': 1.2.14 - '@rolldown/binding-linux-ppc64-gnu': 1.2.14 - '@rolldown/binding-linux-s390x-gnu': 1.2.14 - '@rolldown/binding-linux-x64-gnu': 1.2.14 - '@rolldown/binding-linux-x64-musl': 1.2.14 - '@rolldown/binding-openharmony-arm64': 1.2.14 - '@rolldown/binding-win32-arm64-msvc': 1.2.14 - '@rolldown/binding-win32-x64-msvc': 1.2.14 + '@rolldown/binding-android-arm-eabi': 1.2.15 + '@rolldown/binding-android-arm64': 1.2.15 + '@rolldown/binding-darwin-arm64': 1.2.15 + '@rolldown/binding-darwin-x64': 1.2.15 + '@rolldown/binding-freebsd-x64': 1.2.15 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.15 + '@rolldown/binding-linux-arm64-gnu': 1.2.15 + '@rolldown/binding-linux-arm64-musl': 1.2.15 + '@rolldown/binding-linux-ppc64-gnu': 1.2.15 + '@rolldown/binding-linux-s390x-gnu': 1.2.15 + '@rolldown/binding-linux-x64-gnu': 1.2.15 + '@rolldown/binding-linux-x64-musl': 1.2.15 + '@rolldown/binding-openharmony-arm64': 1.2.15 + '@rolldown/binding-win32-arm64-msvc': 1.2.15 + '@rolldown/binding-win32-x64-msvc': 1.2.15 run-parallel@1.2.0: dependencies: @@ -6630,6 +6779,8 @@ snapshots: semver@7.8.5: {} + setimmediate@1.0.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -6850,6 +7001,8 @@ snapshots: undici-types@8.9.0: {} + undici@6.29.1: {} + unicorn-magic@0.4.1: {} unist-util-is@4.1.0: {} @@ -6869,7 +7022,7 @@ snapshots: unrs-resolver@1.12.2: dependencies: - napi-postinstall: 0.3.5 + napi-postinstall: 0.3.7 optionalDependencies: '@unrs/resolver-binding-android-arm-eabi': 1.12.2 '@unrs/resolver-binding-android-arm64': 1.12.2 @@ -6911,12 +7064,12 @@ snapshots: verkit@0.5.0: {} - vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1): + vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 postcss: 8.5.31 - rolldown: 1.2.14 + rolldown: 1.2.15 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.6.5 @@ -6924,10 +7077,10 @@ snapshots: jiti: 2.7.0 yaml: 2.9.1 - vitest@5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)): + vitest@5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.5(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + '@vitest/mocker': 5.0.5(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -6938,7 +7091,7 @@ snapshots: tinybench: 6.2.2 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1) + vite: 8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1) why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 26.6.5 @@ -6946,7 +7099,7 @@ snapshots: transitivePeerDependencies: - msw - vscode-languageserver-textdocument@1.0.15: {} + vscode-languageserver-textdocument@1.0.16: {} vscode-uri@3.2.0: {}