fix problem with project ID for "given to" project links, fix deep-link redirect for web, update browserslist DB

This commit is contained in:
2026-09-21 07:55:08 -06:00
parent 45503f568a
commit ef476bbb57
13 changed files with 2356 additions and 353 deletions
+1990 -318
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1,4 +1,3 @@
{ {
"name": "giftopia", "name": "giftopia",
"version": "1.4.4", "version": "1.4.4",
+20 -1
View File
@@ -450,7 +450,26 @@ export default class GiftedDialog extends Vue {
TIMEOUTS.MODAL, TIMEOUTS.MODAL,
); );
} else { } else {
this.safeNotify.success("That gift was recorded.", TIMEOUTS.VERY_LONG); if (result.embeddedRecordError) {
// The claim was stored but the server could not record part of it,
// eg. the link to the project this gift came from. Reporting a plain
// success here is how such a gift comes to sit on a project page
// that never shows it.
logger.warn(
"Give recorded but part of it was not:",
result.embeddedRecordError,
);
this.safeNotify.warning(
"That gift was recorded, but some of it was not: " +
result.embeddedRecordError,
TIMEOUTS.MODAL,
);
} else {
this.safeNotify.success(
"That gift was recorded.",
TIMEOUTS.VERY_LONG,
);
}
// Show seed phrase backup reminder if needed // Show seed phrase backup reminder if needed
try { try {
+60
View File
@@ -0,0 +1,60 @@
/**
* @fileoverview Handle ID string handling for the Endorser server
*
* A handle ID is the permanent ID of a claim's entity on Endorser, eg.
* "https://endorser.ch/entity/01D25AVGQG1N8E9JNGK7C7DZRD". Deep links carry it
* with the prefix stripped off, so the two forms both circulate in the app and
* have to be converted deliberately at the boundary between them.
*
* This module holds no dependencies on purpose. Everything here is string
* handling that claims and report queries depend on, so it stays loadable
* without a database, a network client, or a build-time environment.
*
* @module endorserHandleIds
*/
/**
* The prefix for handle IDs, the permanent ID for claims on Endorser
* @constant {string}
*/
export const ENDORSER_CH_HANDLE_PREFIX = "https://endorser.ch/entity/";
/**
* from https://tools.ietf.org/html/rfc3986#section-3
*
* An ID carrying a scheme belongs to whoever issued it; one without a scheme
* names an entity on the Endorser server. (libs/util.ts exports the same
* predicate as isGlobalUri for view code.)
*/
const hasUriScheme = (id: string) => /^[A-Za-z][A-Za-z0-9+.-]+:/.test(id);
/**
* Shorten a full handle ID to the part a deep link carries.
*/
export function stripEndorserPrefix(claimId: string) {
if (claimId && claimId.startsWith(ENDORSER_CH_HANDLE_PREFIX)) {
return claimId.substring(ENDORSER_CH_HANDLE_PREFIX.length);
} else {
return claimId;
}
}
/**
* The inverse of stripEndorserPrefix: expand an ID that names an entity on the
* Endorser server into the full handle ID.
*
* An ID taken from a route parameter goes through here before it reaches a
* claim or a report query. A claim is a credential that outlives this app and
* travels beyond this server, where "01D25AVGQG1N8E9JNGK7C7DZRD" identifies
* nothing; the report endpoints match on the full handle.
*
* An ID that already carries a URI scheme -- a full handle, or a handle from
* another system -- is returned unchanged.
*/
export function addEndorserPrefix(claimId: string) {
if (claimId && !hasUriScheme(claimId)) {
return ENDORSER_CH_HANDLE_PREFIX + claimId;
} else {
return claimId;
}
}
+18 -17
View File
@@ -104,11 +104,14 @@ export const CONTACT_URL_PATH_ENDORSER_CH_OLD = "/contact?jwt=";
*/ */
export const CONTACT_CONFIRM_URL_PATH_TIME_SAFARI = "/contact/confirm/"; export const CONTACT_CONFIRM_URL_PATH_TIME_SAFARI = "/contact/confirm/";
/** // Handle ID string handling lives in its own module so that it can be loaded
* The prefix for handle IDs, the permanent ID for claims on Endorser // and tested without everything this one needs. Re-exported here because that
* @constant {string} // is where callers have always found it.
*/ export {
export const ENDORSER_CH_HANDLE_PREFIX = "https://endorser.ch/entity/"; ENDORSER_CH_HANDLE_PREFIX,
addEndorserPrefix,
stripEndorserPrefix,
} from "./endorserHandleIds";
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> = export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
{ {
@@ -222,14 +225,6 @@ export const containsNonHiddenDid = (obj: any) => {
return testRecursivelyOnStrings(obj, (s: any) => isDid(s) && !isHiddenDid(s)); return testRecursivelyOnStrings(obj, (s: any) => isDid(s) && !isHiddenDid(s));
}; };
export function stripEndorserPrefix(claimId: string) {
if (claimId && claimId.startsWith(ENDORSER_CH_HANDLE_PREFIX)) {
return claimId.substring(ENDORSER_CH_HANDLE_PREFIX.length);
} else {
return claimId;
}
}
// similar logic is found in endorser-mobile // similar logic is found in endorser-mobile
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function removeSchemaContext(obj: any) { export function removeSchemaContext(obj: any) {
@@ -1226,19 +1221,25 @@ export async function createAndSubmitClaim(
}, },
}); });
// The server wraps its result: { success: { claimId, handleId, ... } }.
// Reading the top level instead leaves every field undefined, which hides
// embeddedRecordError -- the server's way of saying it stored the claim but
// could not record part of it, eg. a link to a project.
const result = response.data?.success;
logger.debug("[Claim Submission] ✅ Claim submitted successfully:", { logger.debug("[Claim Submission] ✅ Claim submitted successfully:", {
requestId, requestId,
status: response.status, status: response.status,
handleId: response.data?.handleId, handleId: result?.handleId,
responseSize: JSON.stringify(response.data).length, responseSize: JSON.stringify(response.data).length,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
return { return {
success: true, success: true,
claimId: response.data?.claimId, claimId: result?.claimId,
handleId: response.data?.handleId, handleId: result?.handleId,
embeddedRecordError: response.data?.embeddedRecordError, embeddedRecordError: result?.embeddedRecordError,
}; };
} catch (error: unknown) { } catch (error: unknown) {
// Enhanced error logging with comprehensive context // Enhanced error logging with comprehensive context
+48
View File
@@ -0,0 +1,48 @@
import {
addEndorserPrefix,
stripEndorserPrefix,
ENDORSER_CH_HANDLE_PREFIX,
} from "@/libs/endorserHandleIds";
/**
* A project reached through a deep link arrives with its handle shortened, and
* whatever the app holds at that moment is what it signs into a claim and sends
* to the report endpoints. A short ID in either place is a link to nothing.
*/
const SHORT_ID = "01D25AVGQG1N8E9JNGK7C7DZRD";
const FULL_ID = ENDORSER_CH_HANDLE_PREFIX + SHORT_ID;
describe("addEndorserPrefix", () => {
it("expands an ID that names an entity on this server", () => {
expect(addEndorserPrefix(SHORT_ID)).toEqual(FULL_ID);
});
it("leaves a full handle alone", () => {
expect(addEndorserPrefix(FULL_ID)).toEqual(FULL_ID);
});
it("leaves a handle from another system alone", () => {
// Anything carrying a URI scheme belongs to whoever issued it
expect(addEndorserPrefix("external:some-project")).toEqual(
"external:some-project",
);
expect(addEndorserPrefix("https://other.example/entity/abc")).toEqual(
"https://other.example/entity/abc",
);
expect(addEndorserPrefix("did:ethr:0x0000")).toEqual("did:ethr:0x0000");
});
it("passes an empty ID through rather than making a prefix out of it", () => {
expect(addEndorserPrefix("")).toEqual("");
});
it("is the inverse of stripEndorserPrefix", () => {
expect(addEndorserPrefix(stripEndorserPrefix(FULL_ID))).toEqual(FULL_ID);
expect(stripEndorserPrefix(addEndorserPrefix(SHORT_ID))).toEqual(SHORT_ID);
});
it("is idempotent, so an ID may pass through more than one caller", () => {
expect(addEndorserPrefix(addEndorserPrefix(SHORT_ID))).toEqual(FULL_ID);
});
});
+16 -2
View File
@@ -19,13 +19,19 @@
: "Opening on your Android device..." : "Opening on your Android device..."
}} }}
</p> </p>
<p v-else>Opening the app...</p> <p v-else>Opening the page...</p>
<p class="text-sm mt-2"> <p class="text-sm mt-2">
<span v-if="isMobile" <span v-if="isMobile"
>If the app doesn't open automatically, use one of these >If the app doesn't open automatically, use one of these
options:</span options:</span
> >
<span v-else>Choose how you'd like to open this link:</span> <!-- Not an offer of a choice: openDeepLink has already sent a
desktop browser to the web page, so anyone reading this is
here because that did not happen. -->
<span v-else
>If the page doesn't open automatically, use one of these
options:</span
>
</p> </p>
</div> </div>
@@ -148,6 +154,14 @@ export default class DeepLinkRedirectView extends Vue {
this.openDeepLink(); this.openDeepLink();
} }
/**
* Send the browser on to the link's destination.
*
* A desktop browser goes straight to the web page, so the buttons this view
* renders are only ever seen there when this fails. A mobile browser gets the
* custom scheme, which leaves the page loaded whether or not an app answers,
* which is what the buttons below are really for.
*/
private openDeepLink() { private openDeepLink() {
if (!this.deepLinkUrl || !this.webUrl) { if (!this.deepLinkUrl || !this.webUrl) {
this.pageError = this.pageError =
+12 -2
View File
@@ -246,7 +246,7 @@
<!-- Offers & Gifts to & from this --> <!-- Offers & Gifts to & from this -->
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4 mt-4"> <div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<!-- First, offers on the left--> <!-- First, offers on the left-->
<div class="bg-slate-100 px-4 py-3 rounded-md"> <div class="bg-slate-100 px-4 py-3 rounded-md" data-testId="offers-to">
<div v-if="activeDid && isRegistered" class="mb-4"> <div v-if="activeDid && isRegistered" class="mb-4">
<div class="text-center"> <div class="text-center">
<button <button
@@ -818,9 +818,15 @@ export default class ProjectViewView extends Vue {
// this id to their dialogs. Leaving it empty across an await lets a click // this id to their dialogs. Leaving it empty across an await lets a click
// land in that window and record an offer with no project attached, which // land in that window and record an offer with no project attached, which
// the server then files against the user instead. // the server then files against the user instead.
// A deep link carries the ID with the Endorser prefix stripped off, so
// expand it here rather than letting the short form reach a claim. The
// server's own handle replaces this as soon as loadProject returns; this
// covers the window before that, for the same reason as above.
const pathParam = window.location.pathname.substring("/project/".length); const pathParam = window.location.pathname.substring("/project/".length);
if (pathParam) { if (pathParam) {
this.projectId = decodeURIComponent(pathParam); this.projectId = serverUtil.addEndorserPrefix(
decodeURIComponent(pathParam),
);
} }
const settings = await this.$accountSettings(); const settings = await this.$accountSettings();
@@ -904,6 +910,10 @@ export default class ProjectViewView extends Vue {
try { try {
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers });
if (resp.status === 200) { if (resp.status === 200) {
// Take the handle the server reports for this project. The prefix is
// that deployment's configuration rather than something this app can
// know, and this is the exact string the report endpoints match on.
this.projectId = resp.data.handleId || this.projectId;
const startTime = resp.data.claim?.startTime; const startTime = resp.data.claim?.startTime;
if (startTime != null) { if (startTime != null) {
const startDateTime = new Date(startTime); const startDateTime = new Date(startTime);
@@ -79,7 +79,9 @@ test('Record item given from image-share', async ({ page }) => {
// on shared photo page, choose the gift option // on shared photo page, choose the gift option
await page.getByRole('button').filter({ hasText: /gift/i }).click(); await page.getByRole('button').filter({ hasText: /gift/i }).click();
await page.getByTestId('imagery').getByRole('img').isVisible(); // (isVisible() answered immediately and the answer went nowhere, so the
// shared image was never actually checked.)
await expect(page.getByTestId('imagery').getByRole('img')).toBeVisible();
await page.getByPlaceholder('What was received').fill(finalTitle); await page.getByPlaceholder('What was received').fill(finalTitle);
await page.getByRole('spinbutton').fill('2'); await page.getByRole('spinbutton').fill('2');
await page.getByRole('button', { name: 'Sign & Send' }).click(); await page.getByRole('button', { name: 'Sign & Send' }).click();
@@ -34,12 +34,14 @@ async function testProjectGive(page: Page, isToProject: boolean) {
// refresh the page // refresh the page
await page.reload(); await page.reload();
// check that the give is in the list // Check that the give is in the list.
await page // (The line that used to sit here called isVisible() and threw the answer
.getByTestId(selector) // away, so it passed whether or not the give showed. It also looked under
.locator('div ul li:first-child') // 'div ul li', which gives-from has no room for: its ul sits directly under
.filter({ hasText: finalTitle }) // the test id, while gives-to wraps its own in a div.)
.isVisible(); await expect(
page.getByTestId(selector).locator('li').filter({ hasText: finalTitle }),
).toBeVisible();
} }
test('Record a give to a project', async ({ page }) => { test('Record a give to a project', async ({ page }) => {
@@ -0,0 +1,163 @@
import { test, expect, Page } from '@playwright/test';
import { importUser } from './testUtils';
/**
* A project reached through a shared link arrives with its handle shortened:
* the link carries "01ABC..." where a project opened from a list carries
* "https://endorser.ch/entity/01ABC...". Whatever the page holds at that moment
* is what it signs into every claim made from it, and the server matches the
* full handle, so a short ID leaves a claim recorded and attached to nothing:
* the gift is accepted, reports a success, and never appears on the project.
*
* Spec 37 records the same gifts on a project opened from the discover list,
* where the handle is already full. Only the way in differs here, and that is
* the whole point: these tests would have passed while the shared-link route
* was broken, so they are worth their own file rather than a parameter on 37.
*/
/** The prefix the server puts in front of an entity's ID to make its handle. */
const ENTITY_HANDLE_PREFIX = 'https://endorser.ch/entity/';
/**
* Open a project from the discover list and return its ID in both forms: the
* full handle the app holds when it came from a list, and the short form a
* shared link carries.
*/
async function findProjectIds(
page: Page,
): Promise<{ fullId: string; shortId: string }> {
await page.goto('./discover');
await page.getByTestId('closeOnboardingAndFinish').click();
await page.locator('ul#listDiscoverResults li:first-child a').click();
await page.waitForURL(/\/project\//);
const fullId = decodeURIComponent(
new URL(page.url()).pathname.substring('/project/'.length),
);
expect(fullId).toContain(ENTITY_HANDLE_PREFIX);
return { fullId, shortId: fullId.substring(ENTITY_HANDLE_PREFIX.length) };
}
/**
* Open a project the way a shared link does, so the page holds only the short
* ID. The redirect page sends a desktop browser straight on to the project,
* which is the same place the native handler routes to.
*/
async function openProjectByDeepLink(page: Page, shortId: string) {
await page.goto('./deep-link/project/' + shortId);
// The whole pathname, because "/deep-link/project/<id>" also ends in
// "/project/<id>" and a looser wait returns before the redirect happens.
await page.waitForURL((url) => url.pathname === '/project/' + shortId);
}
/**
* Record a gift on the open project and check that the project shows it.
*
* @param isToProject true gives to the project, false gives from it. The two
* travel in different fields of the claim -- what the gift fulfills, and who
* provided it -- so a short ID can break one and leave the other working.
*/
async function recordGiftOnOpenProject(page: Page, isToProject: boolean) {
const selector = isToProject ? 'gives-to' : 'gives-from';
const finalTitle = 'Gift ' + Math.random().toString(36).substring(2, 6);
const randomNonZeroNumber = Math.floor(Math.random() * 99) + 1;
await page.getByTestId(selector).locator('div:first-child div button').click();
await page.getByPlaceholder('What was given').fill(finalTitle);
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
await page.getByRole('button', { name: 'Sign & Send' }).click();
// Anything short of a plain success means the server could not record part
// of the claim, which is exactly how an unlinked gift used to be reported.
await expect(page.getByText('That gift was recorded.')).toBeVisible();
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
// The list is built from a report query that matches the project's full
// handle, so this is the assertion that the claim was linked and not merely
// stored.
// ("li" rather than "div ul li": the two sections nest their lists
// differently, and gives-from puts its ul directly under the test id.)
await page.reload();
await expect(
page.getByTestId(selector).locator('li').filter({ hasText: finalTitle }),
).toBeVisible();
}
test('Record a give from a project opened by its shared-link ID', async ({
page,
}) => {
test.setTimeout(60000);
await importUser(page, '00');
const { shortId } = await findProjectIds(page);
await openProjectByDeepLink(page, shortId);
await recordGiftOnOpenProject(page, false);
});
test('Record a give to a project opened by its shared-link ID', async ({
page,
}) => {
test.setTimeout(60000);
await importUser(page, '00');
const { shortId } = await findProjectIds(page);
await openProjectByDeepLink(page, shortId);
await recordGiftOnOpenProject(page, true);
});
test('Record an offer on a project opened by its shared-link ID', async ({
page,
}) => {
test.setTimeout(60000);
const description = `Offering of ${Math.random().toString(36).substring(2, 5)}`;
await importUser(page, '00');
const { shortId } = await findProjectIds(page);
await openProjectByDeepLink(page, shortId);
// An offer carries the project in its own field, and the buttons that open
// these dialogs render before the project finishes loading, so an offer made
// straight after landing is the case most likely to escape with a short ID.
await expect(page.getByTestId('offerButton')).toBeVisible();
await page.getByTestId('offerButton').click();
await page.getByTestId('inputDescription').fill(description);
await page
.getByTestId('inputOfferAmount')
.locator('input')
.fill(String(Math.floor(Math.random() * 998) + 1));
await page.getByRole('button', { name: 'Sign & Send' }).click();
await page.getByRole('alert').filter({ hasText: 'Success' }).getByRole('button').click();
// The project's own offer list comes from a report query on the plan, so the
// description showing up here says the project reference survived. Reading
// the offer's claim instead would prove nothing: the claim holds whatever was
// signed, linked or not.
await page.reload();
await expect(
page.getByTestId('offers-to').locator('li').filter({ hasText: description }),
).toBeVisible();
});
test('A shared project link lands on the project holding only the short ID', async ({
page,
}) => {
test.setTimeout(60000);
await importUser(page, '00');
const { fullId, shortId } = await findProjectIds(page);
await page.goto('./deep-link/project/' + shortId);
await page.waitForURL((url) => url.pathname.startsWith('/project/'));
// This is where the short ID comes from, and the condition the three tests
// above depend on. If shared links ever carry the full handle instead, this
// is the test that should fail first, because those three would still pass
// while no longer describing anything real.
const landedId = decodeURIComponent(
new URL(page.url()).pathname.substring('/project/'.length),
);
expect(landedId).toEqual(shortId);
expect(landedId).not.toEqual(fullId);
// The project still has to load from that short ID, or the page is useless
// whatever it does with claims.
await expect(page.getByTestId('offerButton')).toBeVisible();
});
+5 -1
View File
@@ -232,7 +232,11 @@ test('Add contact, copy details, delete, and import from paste & from file', asy
// see contact details on the second contact // see contact details on the second contact
await page.getByTestId('contactListItem').nth(1).locator('h2 > a').click(); await page.getByTestId('contactListItem').nth(1).locator('h2 > a').click();
await page.getByRole('heading', { name: 'Identifier Details' }).isVisible(); // (isVisible() answered immediately and the answer went nowhere, so this
// neither waited for the page nor checked it.)
await expect(
page.getByRole('heading', { name: 'Identifier Details' }),
).toBeVisible();
// remove contact // remove contact
await page.locator('button > svg.fa-trash-can').click(); await page.locator('button > svg.fa-trash-can').click();
await page.locator('div[role="alert"] button:has-text("Yes")').click(); await page.locator('div[role="alert"] button:has-text("Yes")').click();
+13 -4
View File
@@ -110,12 +110,21 @@ export async function switchToUser(page: Page, did: string): Promise<void> {
await switchIdentityLink.click(); await switchIdentityLink.click();
} }
const didElem = await page.locator(`code:has-text("${did}")`); // (A locator is not a promise, so awaiting one does nothing, and isVisible()
await didElem.isVisible(); // answers immediately instead of waiting. Both lines that used to sit here
// looked like waits and were not; expect() is what actually polls.)
const didElem = page.locator(`code:has-text("${did}")`);
await expect(didElem).toBeVisible();
await didElem.click(); await didElem.click();
// wait for the switch to happen and the account page to fully load // Confirm the switch took, rather than returning while the app is still on
await page.getByTestId("didWrapper").locator('code:has-text("did:")'); // whatever page the click landed on. (The line that used to sit here waited
// for the account page's DID without going back to the account page, so it
// could only ever have passed by accident; it never ran as a check at all.
// Every caller navigates straight afterwards, so ending up here costs them
// nothing.)
await page.goto("./account");
await expect(page.getByTestId("didWrapper").locator("code")).toHaveText(did);
} }
export function createContactName(did: string): string { export function createContactName(did: string): string {