From 5a879a43462081607a9d6843f6200da7ff71040d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:54:33 -0700 Subject: [PATCH 1/5] chore(deps): bump smtp-server from 3.19.23 to 3.19.24 (#4383) Bumps [smtp-server](https://github.com/nodemailer/smtp-server) from 3.19.23 to 3.19.24. - [Release notes](https://github.com/nodemailer/smtp-server/releases) - [Changelog](https://github.com/nodemailer/smtp-server/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodemailer/smtp-server/compare/v3.19.23...v3.19.24) --- updated-dependencies: - dependency-name: smtp-server dependency-version: 3.19.24 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 16 ++++++++-------- src/backend/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index c0f4851ee..ed7268d95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15589,13 +15589,13 @@ "license": "MIT" }, "node_modules/smtp-server": { - "version": "3.19.23", - "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.23.tgz", - "integrity": "sha512-t6AOBe7RR9Y/9Kox4e5SIm7wS2ZA8P0VuhVEZ5Yiox0WyevX+rMjATzIwEX3A+B6SGwnJ8QvoHtJAhbJYbjy3A==", + "version": "3.19.24", + "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.24.tgz", + "integrity": "sha512-alzRmwDr5ywR8xo6F2AXqTMWSEjhjC+qB/yHD+zcn2PSEpsJTJ/5O7QdDT4pGVvNNwE+1jhA1ax+hMwleTceWw==", "license": "MIT-0", "dependencies": { "ipv6-normalize": "1.0.1", - "nodemailer": "10.1.2", + "nodemailer": "10.1.4", "punycode.js": "2.3.1" }, "engines": { @@ -15603,9 +15603,9 @@ } }, "node_modules/smtp-server/node_modules/nodemailer": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.2.tgz", - "integrity": "sha512-rnQn9Rzh8RAwcp42MVsIvxbsv6YZ2a2/av+uyJ/LPU1+2QSlrRgFsWWZTKenyRUqkIl8urOI0c4+CEcmFdsvQw==", + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.4.tgz", + "integrity": "sha512-EsMnOR2oRv4LGnWvHgcVYkkQ4O2j8KO4WTw6l+PHR4sOXo8SAgIraxtEnGDmotjbhv0hDsu3IuDIFZm8iXwO4A==", "license": "MIT-0", "engines": { "node": ">=20.0.0" @@ -17724,7 +17724,7 @@ "postal-mime": "^3.0.1", "replicate": "^1.0.0", "sharp": "^0.35.5", - "smtp-server": "^3.19.23", + "smtp-server": "^3.19.24", "socket.io": "^4.8.4", "svg-captcha": "^1.4.0", "together-ai": "^0.33.0", diff --git a/src/backend/package.json b/src/backend/package.json index 523ee47c4..6ee76e724 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -60,7 +60,7 @@ "postal-mime": "^3.0.1", "replicate": "^1.0.0", "sharp": "^0.35.5", - "smtp-server": "^3.19.23", + "smtp-server": "^3.19.24", "socket.io": "^4.8.4", "svg-captcha": "^1.4.0", "together-ai": "^0.33.0", From 0167a9d10ef040f58744d2f405e2323d1e45463d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:54:47 -0700 Subject: [PATCH 2/5] chore(deps): bump fast-uri from 3.1.7 to 3.1.9 in /src/puter-js (#4336) Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.7 to 3.1.9. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.7...v3.1.9) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/puter-js/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/puter-js/package-lock.json b/src/puter-js/package-lock.json index ca6d25da9..e8932d75b 100644 --- a/src/puter-js/package-lock.json +++ b/src/puter-js/package-lock.json @@ -1007,9 +1007,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, "funding": [ { From 241fb7be8ac1650a14bc8796da2be980ae7ec87d Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 09:08:39 -0700 Subject: [PATCH 3/5] feat(dashboard): simplify the share modal - The access level and Share button appear beside the field only once someone is entered, instead of a full-width button that sat disabled. - The access list sits directly under the field. Each person's menu ends with Remove access (or Cancel invitation), replacing the trash column; the inline confirmation stays. - General access is one row: a lock or globe icon, the switch with its explanation beneath, and the level on the right. - A footer holds Copy link and Done. Copy link is offered for any single item, since the link opens it for anyone who has access, and confirms in place. Status messages live in the footer; successes fade. Co-Authored-By: Claude Opus 5.5 --- src/gui/src/UI/Dashboard/UIShareModal.js | 164 ++++++++---- src/gui/src/UI/Dashboard/UIShareModal.test.js | 70 ++++- src/gui/src/css/dashboard.css | 246 +++++++++++------- 3 files changed, 335 insertions(+), 145 deletions(-) diff --git a/src/gui/src/UI/Dashboard/UIShareModal.js b/src/gui/src/UI/Dashboard/UIShareModal.js index 2a111e65b..72a071dfe 100644 --- a/src/gui/src/UI/Dashboard/UIShareModal.js +++ b/src/gui/src/UI/Dashboard/UIShareModal.js @@ -21,7 +21,6 @@ import path from '../../lib/path.js'; import item_icon from '../../helpers/itemIcon.js'; import { is_owned_by_me, owner_of_path } from '../../helpers/pathOwner.js'; import { invalidate_shared_roots } from '../../helpers/sharedAccess.js'; -import { icons } from '../../helpers/actionIcons.js'; import { mode_label, options_for } from '../../helpers/shareModes.js'; import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js'; import { avatarHue, avatarInitial } from '../../helpers/shareAvatar.js'; @@ -43,6 +42,19 @@ const { html_encode } = window; const closeIcon = ``; const chevronIcon = ``; +const linkIcon = ``; +const lockIcon = ``; +const globeIcon = ``; +const checkIcon = ``; + +// The option at the foot of a person's access menu that takes their access +// away. Never a mode the API knows, so it can't be confused with one. +const REMOVE_OPTION = '__remove'; + +// How long a success message stays up. Errors stay until the next action. +const SUCCESS_MS = 4000; +// How long Copy link reads "Link copied" before going back. +const COPIED_MS = 2000; // How many item icons the header fans out before it stops adding to the pile. const MAX_STACKED_ICONS = 3; @@ -186,31 +198,43 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse - + ${allow_manage ? `` : ''} + + @@ -226,9 +250,11 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse const $list = $overlay.find('.share-modal-list'); const $recipient = $overlay.find('.share-modal-recipient'); const $submit = $overlay.find('.share-modal-submit'); + const $add_actions = $overlay.find('.share-modal-add-actions'); const $link_access = $overlay.find('.share-modal-link-access'); const $link_mode = $overlay.find('.share-modal-link-mode'); const $link_note = $overlay.find('.share-modal-general-note'); + const $link_icon = $overlay.find('.share-modal-general-icon'); const $copy_link = $overlay.find('.share-modal-copy-link'); /** @@ -238,6 +264,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse let link_state = { access: total > MAX_ITEMS_PER_REQUEST ? 'unchecked' : 'restricted', mode: null }; /** The one item's uid, which its link is built on. Single item only. */ let single_uid = is_multi ? null : (targets[0].fsentry?.uid ?? null); + $copy_link.prop('hidden', ! single_uid); // Focus returns to wherever the user was (usually the shared row) when // the modal closes. While it's up, the recipient input takes it on @@ -251,11 +278,18 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse $recipient.get(0)?.focus({ preventScroll: true }); } + // A success confirms what the list already shows, so it fades on its own; + // an error stays until the user does something else. + let status_timer = null; + let copied_timer = null; + let closed = false; const close = () => { if ( closed ) return; closed = true; $overlay.removeClass('share-modal-show'); + clearTimeout(status_timer); + clearTimeout(copied_timer); $(document).off(`keydown.share-modal-${modal_id}`); // The recipient field listens on the document to know when a click // landed outside its suggestions; that has to come off with the dialog. @@ -302,6 +336,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse // Both take HTML-safe text; `error_html` encodes the one raw source. const show_error = (html) => { + clearTimeout(status_timer); $status .removeClass('share-modal-status-success') .addClass('share-modal-status-error') @@ -309,10 +344,14 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse }; const show_success = (html) => { + clearTimeout(status_timer); $status .removeClass('share-modal-status-error') .addClass('share-modal-status-success') .html(html); + status_timer = setTimeout(() => { + if ( $status.hasClass('share-modal-status-success') ) clear_status(); + }, SUCCESS_MS); }; const error_html = (err) => (err?.message @@ -392,24 +431,24 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse row += via; row += ''; - if ( can_change ) { - // The accessible names carry the person: a list where every row - // reads as bare "Access level" / "Remove access" leaves a screen - // reader user unable to tell whose grant a control changes. - row += ``; - } else { - let fixed_mode = group.inheritedMode; - if ( group.pending ) fixed_mode = group.pendingMode; - else if ( group.directPaths.length ) fixed_mode = group.mode; - row += ``; - } + let fixed_mode = group.inheritedMode; + if ( group.pending ) fixed_mode = group.pendingMode; + else if ( group.directPaths.length ) fixed_mode = group.mode; + const fixed_label = fixed_mode ? mode_label(fixed_mode) : i18n('share_access_mixed'); - if ( can_revoke ) { - const label = group.pending ? i18n('share_cancel_invite') : i18n('share_remove_access'); - const aria = group.pending - ? i18n('share_cancel_invite_for', { recipient: group.name }) - : i18n('share_remove_access_for', { recipient: group.name }); - row += ``; + if ( can_change || can_revoke ) { + // One menu per person: their access level, then a way to take it + // away. A grant they can only withdraw shows its level as a fixed + // first entry. The accessible name carries the person, so a screen + // reader user can tell whose access the control changes. + const levels = can_change + ? options_for(group.mode, { allow_manage }) + : ``; + const remove_label = group.pending ? i18n('share_cancel_invite') : i18n('share_remove_access'); + row += ``; + } else { + row += ``; } row += ''; return row; @@ -500,8 +539,14 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse /** Put the control where the listings say the selection stands. */ const render_link_access = () => { + // The link opens the item for anyone who has access, so it's worth + // copying whether or not it's open to everyone. + $copy_link.prop('hidden', ! single_uid); if ( ! allow_manage ) return; const { access, mode } = link_state; + $link_icon + .toggleClass('share-modal-general-icon-open', access === 'anyone') + .html(access === 'anyone' ? globeIcon : (access === 'mixed' ? linkIcon : lockIcon)); $link_access.val(access); $link_mode.prop('hidden', access !== 'anyone'); // `null` rests the mode on the "Mixed" placeholder, as the rows do. @@ -517,11 +562,10 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse note = i18n(is_multi ? 'share_link_restricted_note_items' : 'share_link_restricted_note'); } $link_note.html(note); - $copy_link.prop('hidden', ! (access === 'anyone' && single_uid)); }; // -- Dismissal wiring -- - $overlay.on('click', '.share-modal-close', close); + $overlay.on('click', '.share-modal-close, .share-modal-done', close); // Backdrop close goes by where the press STARTED: a drag that begins in // the recipient input (text selection) and releases over the backdrop // registers its click on the overlay, and must not eat the typed name. @@ -542,7 +586,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse if ( $confirm.length ) { const key = $confirm.attr('data-key'); render(last_groups); - focus_list_control('.share-modal-revoke', key) || focus_dialog(); + focus_list_control('.share-modal-row-mode', key) || focus_dialog(); return; } close(); @@ -678,6 +722,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse const mode = $(this).val(); if ( mode ) set_link_access(mode, this); }); + // The button confirms the copy itself, then goes back to what it was. $overlay.on('click', '.share-modal-copy-link', async function () { if ( ! single_uid ) return; try { @@ -685,14 +730,22 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse { owner: targets[0].owner, uid: single_uid, name: targets[0].name }, window.gui_origin, )); - show_success(i18n('share_link_copied')); + clearTimeout(copied_timer); + $copy_link.addClass('share-modal-copied'); + $copy_link.find('.share-modal-copy-icon').html(checkIcon); + $copy_link.find('.share-modal-copy-label').html(i18n('share_link_copied')); + copied_timer = setTimeout(() => { + $copy_link.removeClass('share-modal-copied'); + $copy_link.find('.share-modal-copy-icon').html(linkIcon); + $copy_link.find('.share-modal-copy-label').html(i18n('share_copy_link')); + }, COPIED_MS); } catch (err) { show_error(error_html(err)); } }); // The uid the link is built on, when the row did not carry it. - if ( allow_manage && ! is_multi && ! single_uid ) { + if ( ! is_multi && ! single_uid ) { puter.fs.stat(targets[0].path).then((stat) => { if ( closed ) return; single_uid = stat?.uid ?? null; @@ -706,6 +759,14 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse // A share on its way keeps the button down, though the field is edited. let sharing = false; + // The level and Share button only show once there is someone to share + // with, and stay while a share is on its way. + const sync_add_actions = () => { + const chosen = picker.recipient(); + $submit.prop('disabled', sharing || ! chosen); + $add_actions.prop('hidden', ! (chosen || sharing)); + }; + // One field for every kind of recipient: a colleague, a whole team, someone // shared with before, or an address typed from scratch. const picker = shareRecipientPicker({ @@ -715,7 +776,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse // re-grant what their row already shows. excluded: () => last_groups.map((group) => group.key), onChange: () => { - $submit.prop('disabled', sharing || ! picker.recipient()); + sync_add_actions(); // Choosing again retires a stale success/error message. clear_status(); }, @@ -757,7 +818,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse sharing = false; // Clear only what we sent; a name typed mid-flight shouldn't vanish. if ( $recipient.val().trim() === recipient ) picker.clear(); - $submit.prop('disabled', ! picker.recipient()); + sync_add_actions(); // A pair the backend refused doesn't fail the others, so say how // many items actually landed rather than implying all of them did. const granted = created?.length ?? 0; @@ -788,7 +849,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse } catch (err) { sharing = false; show_error(error_html(err)); - $submit.prop('disabled', ! picker.recipient()); + sync_add_actions(); // Disabling the clicked button dropped focus to ; put it // where the correction happens. $recipient.get(0)?.focus({ preventScroll: true }); @@ -803,6 +864,10 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse const group = group_for(key); const mode = $(this).val(); if ( ! group || ! mode ) return; + if ( mode === REMOVE_OPTION ) { + confirm_revoke(key); + return; + } $(this).prop('disabled', true); try { await grant_access(recipient_of(group), mode, group.directPaths); @@ -841,9 +906,8 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse if ( ! focus_moved_on() ) focus_list_control('.share-modal-row-mode', key) || focus_dialog(); }); - // -- Revoke, confirmed inline in the row -- - $overlay.on('click', '.share-modal-revoke', function () { - const key = $(this).attr('data-key'); + // -- Revoke, picked from the person's menu and confirmed inline in the row -- + const confirm_revoke = (key) => { const group = group_for(key); if ( ! group ) return; // An invitation is withdrawn, access is taken away; the prompt must @@ -866,11 +930,11 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse // One confirmation at a time: opening a second one restores the first // row. The re-render replaces every row, so re-find this person's - // instead of using the (now detached) clicked button. + // instead of using the (now detached) menu that was changed. if ( $list.find('.share-modal-row-confirm').length ) { render(last_groups); } - $list.find('.share-modal-revoke') + $list.find('.share-modal-row-mode') .filter((_, el) => $(el).attr('data-key') === key) .closest('.share-modal-row') .replaceWith(` @@ -883,12 +947,12 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse `); $list.find('.share-modal-confirm-cancel').trigger('focus'); - }); + }; $overlay.on('click', '.share-modal-confirm-cancel', function () { const key = $(this).closest('.share-modal-row-confirm').attr('data-key'); render(last_groups); - focus_list_control('.share-modal-revoke', key) || focus_dialog(); + focus_list_control('.share-modal-row-mode', key) || focus_dialog(); }); $overlay.on('click', '.share-modal-confirm-remove', async function () { @@ -925,7 +989,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse } catch (err) { show_error(error_html(err)); render(last_groups); - focus_list_control('.share-modal-revoke', key) || focus_dialog(); + focus_list_control('.share-modal-row-mode', key) || focus_dialog(); } }); diff --git a/src/gui/src/UI/Dashboard/UIShareModal.test.js b/src/gui/src/UI/Dashboard/UIShareModal.test.js index a8f4c1c81..e8e427293 100644 --- a/src/gui/src/UI/Dashboard/UIShareModal.test.js +++ b/src/gui/src/UI/Dashboard/UIShareModal.test.js @@ -124,7 +124,7 @@ describe('UIShareModal removing access', () => { UIShareModal({ path: '/alice/a.txt' }); await settle(); - $('.share-modal-revoke').trigger('click'); + $('.share-modal-row-mode').val('__remove').trigger('change'); $('.share-modal-confirm-remove').trigger('click'); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); @@ -133,9 +133,77 @@ describe('UIShareModal removing access', () => { finish(); await settle(); }); + + it('asks before removing, and removes nothing until confirmed', async () => { + puter.fs.getShares = vi.fn(async () => [{ holder: 'bob', mode: 'read', issuer: 'alice' }]); + UIShareModal({ path: '/alice/a.txt' }); + await settle(); + + $('.share-modal-row-mode').val('__remove').trigger('change'); + expect(puter.fs.share).not.toHaveBeenCalled(); + expect(puter.fs.unshare).not.toHaveBeenCalled(); + expect($('.share-modal-row-confirm').length).toBe(1); + + $('.share-modal-confirm-cancel').trigger('click'); + expect($('.share-modal-row-confirm').length).toBe(0); + expect($('.share-modal-row-mode').val()).toBe('read'); + + $('.share-modal-row-mode').val('__remove').trigger('change'); + $('.share-modal-confirm-remove').trigger('click'); + await settle(); + expect(puter.fs.unshare).toHaveBeenCalledWith({ paths: ['/alice/a.txt'], recipient: 'bob' }); + }); + + it('offers cancelling an invitation from its menu', async () => { + puter.fs.getShares = vi.fn(async () => [ + { holder: null, recipientEmail: 'carol@example.com', pending: true, mode: 'write', issuer: 'alice' }, + ]); + UIShareModal({ path: '/alice/a.txt' }); + await settle(); + + const $menu = $('.share-modal-row-mode'); + expect($menu.length).toBe(1); + expect($menu.find('option[value="__remove"]').text()).toBe(i18n('share_cancel_invite')); + }); +}); + +describe('UIShareModal footer', () => { + it('offers the link for one item even while it is restricted', async () => { + UIShareModal({ path: '/alice/a.txt' }); + await settle(); + expect($('.share-modal-copy-link').prop('hidden')).toBe(false); + expect($('.share-modal-link-access').val()).toBe('restricted'); + }); + + it('has no link to copy for several items', async () => { + UIShareModal({ items: [{ path: '/alice/a.txt' }, { path: '/alice/b.txt' }] }); + await settle(); + expect($('.share-modal-copy-link').length).toBe(0); + }); + + it('closes on Done', async () => { + UIShareModal({ path: '/alice/a.txt' }); + await settle(); + $('.share-modal-done').trigger('click'); + expect($('.share-modal-overlay').hasClass('share-modal-show')).toBe(false); + }); }); describe('UIShareModal adding people', () => { + it('shows the level and Share only once someone is entered', async () => { + UIShareModal({ path: '/alice/a.txt' }); + await settle(); + expect($('.share-modal-add-actions').prop('hidden')).toBe(true); + + picked.recipient = { value: 'bob', label: 'bob' }; + picked.onChange(); + expect($('.share-modal-add-actions').prop('hidden')).toBe(false); + + picked.recipient = null; + picked.onChange(); + expect($('.share-modal-add-actions').prop('hidden')).toBe(true); + }); + it('keeps Share down while a share is on its way, though the name is edited', async () => { let finish; puter.fs.share = vi.fn(() => new Promise((resolve) => { diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index a027c98a9..6e723ba56 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -2770,6 +2770,35 @@ button.share-modal-title-sub:focus-visible { padding: 16px 18px 18px; } +/* Footer: the status line, then Copy link and Done. Outside the scrolling + body, so a message is in view wherever the list is scrolled to. */ +.share-modal-footer { + flex-shrink: 0; + padding: 12px 18px 14px; + border-top: 1px solid var(--dashboard-border); +} +.share-modal-footer-row { + display: flex; + align-items: center; + gap: 8px; +} +.share-modal-done { + margin-left: auto; +} +.share-modal-copy-link { + gap: 6px; +} +.share-modal-copy-link[hidden] { + display: none; +} +.share-modal-copy-icon { + display: flex; +} +.share-modal-copy-link.share-modal-copied { + border-color: var(--dashboard-success-border); + color: var(--dashboard-success-text); +} + /* Add people. style.css's input[type=text] / select rules match these controls at the SAME specificity, so everything must be spelled out here (twice for focus — input[type=text]:focus sets padding 7px / border 2px). @@ -2778,12 +2807,25 @@ button.share-modal-title-sub:focus-visible { display: flex; gap: 8px; } +.share-modal-add-actions { + display: flex; + gap: 8px; + flex-shrink: 0; + animation: share-modal-reveal 150ms ease-out; +} +.share-modal-add-actions[hidden] { + display: none; +} +@keyframes share-modal-reveal { + from { opacity: 0; transform: translateX(6px); } +} input.share-modal-recipient { -webkit-appearance: none; appearance: none; box-sizing: border-box; flex: 1 1 auto; width: auto; + height: 36px; min-width: 0; /* On narrow screens the placeholder outgrows the field; fade it out with an ellipsis instead of clipping mid-letter. */ @@ -2834,6 +2876,9 @@ select.share-modal-mode, select.share-modal-link-mode { flex-shrink: 0; } +select.share-modal-mode { + height: 36px; +} select.share-modal-mode:focus, select.share-modal-link-access:focus, select.share-modal-link-mode:focus, @@ -2875,56 +2920,75 @@ select.share-modal-row-mode:disabled { } /* General access: the owner's switch between people-only and anyone with - the link, below the people and team controls. The copy button is the team - button's quieter sibling. */ + the link, as one row shaped like a person's — an icon where the avatar + goes, the switch and what it means, and the level on the right. */ .share-modal-general { - margin-top: 18px; - padding-top: 16px; - border-top: 1px solid var(--dashboard-border); + margin-top: 20px; } .share-modal-general .share-modal-heading { - margin: 0 0 8px; + margin: 0 0 6px; } .share-modal-general-row { display: flex; - gap: 8px; + align-items: center; + gap: 10px; + padding: 4px 0; } -select.share-modal-link-access { +.share-modal-general-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--dashboard-hover); + color: var(--dashboard-text-secondary); + transition: background 0.15s, color 0.15s; +} + +.share-modal-general-icon.share-modal-general-icon-open { + background: var(--dashboard-success-background); + color: var(--dashboard-success-text); +} + +.share-modal-general-text { flex: 1 1 auto; min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; +} + +/* Reads as the row's title; the padding lets the hover fill reach past the + text while the text itself lines up with the note below. */ +select.share-modal-link-access { + max-width: calc(100% + 8px); + margin-left: -8px; + padding: 3px 24px 3px 8px; + border-color: transparent; + background-color: transparent; + background-position: right 6px center; + font-size: 13.5px; + font-weight: 600; + field-sizing: content; +} +select.share-modal-link-access:focus { + padding: 3px 24px 3px 8px; +} +@media (hover: hover) { + select.share-modal-link-access:hover:not(:disabled) { + background-color: var(--dashboard-hover); + } } .share-modal-general-note { - margin: 8px 0 0; - font-size: 12.5px; - line-height: 1.45; - color: var(--dashboard-text-hint); -} - -.share-modal-copy-link { - width: 100%; - height: 38px; - margin-top: 10px; - padding: 0 16px; - border: 1px solid var(--dashboard-border); - border-radius: 10px; - background: transparent; - font-size: 13.5px; - font-weight: 600; - font-family: inherit; - color: var(--dashboard-text-primary); - cursor: pointer; - transition: background 0.15s, border-color 0.15s; -} - -@media (hover: hover) { - .share-modal-copy-link:hover:not(:disabled) { - background: var(--dashboard-card-background); - border-color: var(--dashboard-text-hint); - } + font-size: 12px; + line-height: 1.4; + color: var(--dashboard-text-muted); } .share-modal-submit { @@ -2932,14 +2996,14 @@ select.share-modal-link-access { align-items: center; justify-content: center; gap: 8px; - width: 100%; - margin-top: 10px; + flex-shrink: 0; + box-sizing: border-box; padding: 0 16px; - height: 38px; + height: 36px; border: 1px solid var(--dashboard-button-primary); - border-radius: 10px; + border-radius: 8px; background: var(--dashboard-button-primary); - font-size: 13.5px; + font-size: 13px; font-weight: 600; font-family: inherit; color: #fff; @@ -3002,16 +3066,16 @@ select.share-modal-link-access { display:none — a live region revealed in the same frame as its text is unreliably announced, so it stays in the a11y tree between messages. */ .share-modal-status { - margin-top: 12px; - padding: 9px 12px; + margin-bottom: 10px; + padding: 8px 12px; border: 1px solid transparent; border-radius: 8px; - font-size: 13px; + font-size: 12.5px; line-height: 1.45; word-break: break-word; } .share-modal-status:empty { - margin-top: 0; + margin-bottom: 0; padding: 0; border-width: 0; } @@ -3028,28 +3092,27 @@ select.share-modal-link-access { /* Access list */ .share-modal-heading { - margin: 18px 0 2px; font-size: 13px; font-weight: 600; color: var(--dashboard-text-secondary); } +.share-modal-list { + margin-top: 10px; +} .share-modal-row { display: flex; align-items: center; gap: 10px; - padding: 9px 0; - border-bottom: 1px solid var(--dashboard-border); -} -.share-modal-row:last-child { - border-bottom: none; + min-height: 32px; + padding: 5px 0; } .share-modal-avatar { flex-shrink: 0; display: flex; align-items: center; justify-content: center; - width: 30px; - height: 30px; + width: 32px; + height: 32px; border-radius: 50%; font-size: 13px; font-weight: 600; @@ -3116,50 +3179,41 @@ select.share-modal-link-access { font-size: 12.5px; color: var(--dashboard-text-secondary); } +select.share-modal-link-mode, select.share-modal-row-mode { flex-shrink: 0; + max-width: 50%; padding: 5px 24px 5px 8px; border-color: transparent; background-color: transparent; font-size: 12.5px; color: var(--dashboard-text-secondary); background-position: right 6px center; + /* Sized to the level on show rather than the longest entry ("Remove + access"), so the chevron sits next to the text. Where unsupported, + the text at least hugs the chevron. */ + field-sizing: content; + text-align-last: right; +} +select.share-modal-row-mode option, +select.share-modal-link-mode option { + text-align: left; +} +select.share-modal-row-mode option.share-modal-option-danger { + color: var(--dashboard-danger-text); } @media (hover: hover) { + select.share-modal-link-mode:hover:not(:disabled), select.share-modal-row-mode:hover:not(:disabled) { background-color: var(--dashboard-hover); color: var(--dashboard-text-primary); } } +select.share-modal-link-mode:focus, select.share-modal-row-mode:focus { padding: 5px 24px 5px 8px; border: 1px solid var(--select-color); } -.share-modal-revoke { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - padding: 0; - border: none; - border-radius: 8px; - background: transparent; - color: var(--dashboard-text-secondary); - cursor: pointer; - transition: background 0.15s, color 0.15s; -} -@media (hover: hover) { - .share-modal-revoke:hover:not(:disabled) { - background: var(--dashboard-danger-background); - color: var(--dashboard-danger-text); - } -} -.share-modal-revoke:focus-visible { - outline: none; - box-shadow: 0 0 0 3px var(--select-ring); -} .share-modal-empty { margin: 0; padding: 10px 0; @@ -3226,20 +3280,17 @@ select.share-modal-row-mode:focus { cursor: default; } -/* Touch: every control clears 44px. The icon buttons keep their 30-32px - visuals but hit-test at 44x44 via a centered pseudo-element (the 10px - gap to the row's select and the 18px between rows absorb the overhang - without overlap); everything else grows for real via min-height, which - holds whatever the inherited line-height turns out to be. +/* Touch: every control clears 44px. The close button keeps its 32px + visual but hit-tests at 44x44 via a centered pseudo-element; everything + else grows for real via min-height, which holds whatever the inherited + line-height turns out to be. This block sits below every base rule it overrides — a media query adds no specificity, so source order decides. */ @media (pointer: coarse) { - .share-modal-close, - .share-modal-revoke { + .share-modal-close { position: relative; } - .share-modal-close::before, - .share-modal-revoke::before { + .share-modal-close::before { content: ''; position: absolute; left: 50%; @@ -3269,19 +3320,18 @@ select.share-modal-row-mode:focus { select.share-modal-mode, select.share-modal-mode:focus, select.share-modal-link-access, - select.share-modal-link-access:focus, - select.share-modal-link-mode, - select.share-modal-link-mode:focus { + select.share-modal-link-access:focus { min-height: 44px; } + select.share-modal-link-mode, + select.share-modal-link-mode:focus, select.share-modal-row-mode, select.share-modal-row-mode:focus { min-height: 44px; padding-top: 9px; padding-bottom: 9px; } - .share-modal-submit, - .share-modal-copy-link { + .share-modal-submit { height: 44px; } .share-modal-btn-quiet, @@ -3332,9 +3382,14 @@ select.share-modal-row-mode:focus { transform: translateY(0); } /* The sheet sits flush with the physical bottom edge (viewport-fit= - cover), so the scrollable body clears the home indicator itself. */ - .share-modal-body { - padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px)); + cover), so the footer clears the home indicator itself. */ + .share-modal-footer { + padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); + } + /* Copy link and Done split the row rather than stacking. */ + .share-modal-footer-row .dashboard-dialog-btn { + flex: 1 1 0; + min-width: 0; } } @@ -3343,6 +3398,9 @@ select.share-modal-row-mode:focus { .share-modal { transition: none; } + .share-modal-add-actions { + animation: none; + } .share-modal-spinner { animation-duration: 1.6s; } From 49b38f348202b5435e57091a4d581a707440c1c8 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Tue, 27 Oct 2026 09:49:31 -0700 Subject: [PATCH 4/5] fix: seat creation (#4387) --- src/backend/clients/event/types.ts | 4 ++-- src/backend/services/user/SignupService.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index 8e0b651d3..cb3742787 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -208,7 +208,7 @@ export type EventMap = { */ is_temp?: boolean; ip?: string | null; - source?: 'oidc'; + source?: 'oidc' | 'team_provision'; req?: unknown; data?: unknown; abuse?: unknown; @@ -268,7 +268,7 @@ export type EventMap = { ip?: string | null; fingerprint?: string | null; reputation?: number | null; - source?: 'oidc'; + source?: 'oidc' | 'team_provision'; requires_phone_verification: boolean; requires_card_verification: boolean; accepted: boolean; diff --git a/src/backend/services/user/SignupService.ts b/src/backend/services/user/SignupService.ts index ad6f20e03..c7ab6332d 100644 --- a/src/backend/services/user/SignupService.ts +++ b/src/backend/services/user/SignupService.ts @@ -59,7 +59,7 @@ export type SignupGateOutcome = export interface SignupGateInput { req: Request; /** Set for signups that did not come through the signup form. */ - source?: 'oidc'; + source?: 'oidc' | 'team_provision'; data: Record; email: string; isTemp: boolean; From 25a574b57d4356499cbf33533687fd780c7dc8cc Mon Sep 17 00:00:00 2001 From: Neal Shah <30693865+ProgrammerIn-wonderland@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:54:02 -0400 Subject: [PATCH 5/5] GUI for custom domains (#4355) * PUT-2226 * modifications' * routing fixes * secondary custom domain verification --- src/backend/server.test.ts | 52 ++ src/backend/server.ts | 36 +- .../services/permission/appGrantable.test.ts | 3 + .../services/permission/appGrantable.ts | 6 +- src/docs/src/Hosting.md | 1 + src/docs/src/Hosting/attach.md | 70 +++ src/docs/src/Objects.md | 1 + src/docs/src/Objects/customdomain.md | 54 +++ src/docs/src/examples.js | 6 + .../playground/examples/hosting-attach.html | 24 + src/docs/src/sidebar.js | 15 + src/gui/src/IPC.js | 53 ++ src/gui/src/UI/UIPermissionDialog.js | 4 + src/gui/src/UI/UIWindowAttachCustomDomain.js | 456 ++++++++++++++++++ src/gui/src/css/dashboard.css | 130 +++++ src/gui/src/helpers/launchApp.js | 2 +- src/gui/src/i18n/translations/en.js | 37 ++ src/gui/src/initgui.js | 55 +++ src/gui/src/util/popupAuth.js | 2 +- src/gui/src/util/popupAuth.test.js | 13 +- src/puter-js/index.d.ts | 6 +- .../src/modules/UI.attachCustomDomain.test.js | 174 +++++++ src/puter-js/src/modules/UI.js | 114 ++++- src/puter-js/src/modules/hosting/attach.js | 32 ++ .../src/modules/hosting/attach.test.js | 30 ++ src/puter-js/src/modules/hosting/index.js | 4 +- src/puter-js/src/modules/hosting/types.js | 29 ++ .../tests/api/suites/hosting.suite.ts | 12 + .../e2e/fixtures/attach-custom-domain.html | 63 +++ .../e2e/specs/attachCustomDomain.spec.js | 70 +++ 30 files changed, 1513 insertions(+), 41 deletions(-) create mode 100644 src/docs/src/Hosting/attach.md create mode 100644 src/docs/src/Objects/customdomain.md create mode 100644 src/docs/src/playground/examples/hosting-attach.html create mode 100644 src/gui/src/UI/UIWindowAttachCustomDomain.js create mode 100644 src/puter-js/src/modules/UI.attachCustomDomain.test.js create mode 100644 src/puter-js/src/modules/hosting/attach.js create mode 100644 src/puter-js/src/modules/hosting/attach.test.js create mode 100644 src/puter-js/tests/e2e/fixtures/attach-custom-domain.html create mode 100644 src/puter-js/tests/e2e/specs/attachCustomDomain.spec.js diff --git a/src/backend/server.test.ts b/src/backend/server.test.ts index f3d9fd5f5..17ab78c26 100644 --- a/src/backend/server.test.ts +++ b/src/backend/server.test.ts @@ -468,6 +468,58 @@ describe('PuterServer host header validation — permissive modes', () => { }); }); +describe('PuterServer host header validation — custom domains with every host accepted', () => { + let server: PuterServer; + let port: number; + + beforeAll(async () => { + port = await allocateEphemeralPort(); + server = await setupTestServer( + { + port, + domain: 'puter.localhost', + origin: `http://puter.localhost:${port}`, + allow_all_host_values: true, + custom_domains_enabled: true, + static_hosting_domain: 'site.puter.localhost', + } as unknown as IConfig, + { listen: true }, + ); + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + it('still hands a host outside our domains to site hosting', async () => { + const res = await rawRequest(port, '/', { + host: 'www.my-own-domain.example', + }); + expect(res.status).toBe(404); + expect(res.body).toBe('Site not found'); + }); + + it('leaves our own domain to the GUI', async () => { + const res = await rawRequest(port, '/healthcheck', { + host: `puter.localhost:${port}`, + }); + expect(res.status).toBe(200); + }); + + it('never treats an IP or localhost as a custom domain', async () => { + for (const host of [ + `127.0.0.1:${port}`, + `[::1]:${port}`, + `localhost:${port}`, + ]) { + const res = await rawRequest(port, '/healthcheck', { host }); + expect(res.status, host).toBe(200); + const root = await rawRequest(port, '/', { host }); + expect(root.body, host).not.toBe('Site not found'); + } + }); +}); + describe('PuterServer request parsing', () => { let server: PuterServer; let port: number; diff --git a/src/backend/server.ts b/src/backend/server.ts index 927b04919..c87aee715 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -29,6 +29,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { parseArgs } from 'node:util'; import http from 'node:http'; +import { isIP } from 'node:net'; import { puterClients } from './clients'; import { puterControllers } from './controllers'; import { createAuthProbe } from './core/http/middleware/authProbe'; @@ -597,7 +598,32 @@ export class PuterServer { // Build the allowed-domain set from config. this.#app.use((req, res, next) => { + const hostName = (req.headers.host ?? '') + .split(':')[0] + .trim() + .toLowerCase(); + const isOwnHost = + !hostName || + this.#getAllowedDomains().some((d) => + PuterServer.#hostMatchesDomain(hostName, d), + ); + + // Accepting every host still has to tell custom domains apart, or + // they fall through to the API/GUI instead of site hosting. An IP + // or localhost is never one: load balancer health checks and + // in-cluster callers address the server that way. if (config.allow_all_host_values) { + if ( + config.custom_domains_enabled && + !isOwnHost && + req.path !== '/healthcheck' && + // `[` is what an IPv6 literal's `[::1]:port` splits to. + !hostName.startsWith('[') && + isIP(hostName) === 0 && + hostName !== 'localhost' + ) { + req.is_custom_domain = true; + } next(); return; } @@ -613,15 +639,7 @@ export class PuterServer { return; } - const hostName = (req.headers.host ?? '') - .split(':')[0] - .trim() - .toLowerCase(); - const allowed = this.#getAllowedDomains(); - - if ( - allowed.some((d) => PuterServer.#hostMatchesDomain(hostName, d)) - ) { + if (isOwnHost) { next(); return; } diff --git a/src/backend/services/permission/appGrantable.test.ts b/src/backend/services/permission/appGrantable.test.ts index 89a1b9e6b..bf6d903ed 100644 --- a/src/backend/services/permission/appGrantable.test.ts +++ b/src/backend/services/permission/appGrantable.test.ts @@ -95,6 +95,7 @@ describe('parseAppGrantablePermission', () => { }, ], ['events:background', { family: 'backgroundEvents' }], + ['custom-domains:manage', { family: 'customDomains' }], ])('accepts %s', (permission, parsed) => { expect(parse(permission)).toEqual(parsed); }); @@ -118,6 +119,8 @@ describe('parseAppGrantablePermission', () => { '*', 'events', 'events:background:extra', + 'custom-domains', + 'custom-domains:manage:extra', // Modeless, or a mode that does not exist. 'fs', `fs:${FILE}`, diff --git a/src/backend/services/permission/appGrantable.ts b/src/backend/services/permission/appGrantable.ts index 2822307c9..935dac5ae 100644 --- a/src/backend/services/permission/appGrantable.ts +++ b/src/backend/services/permission/appGrantable.ts @@ -62,7 +62,8 @@ export type AppGrantablePermission = appUid: string; segments: string[]; } - | { family: 'backgroundEvents' }; + | { family: 'backgroundEvents' } + | { family: 'customDomains' }; /** One component as it is written canonically: `\` as `\\`, `:` as `\C`. */ const escapeComponent = (component: string): string => @@ -117,6 +118,9 @@ export const parseAppGrantablePermission = ( if (permission === 'events:background') { return { family: 'backgroundEvents' }; } + if (permission === 'custom-domains:manage') { + return { family: 'customDomains' }; + } const parts = PermissionUtil.split(permission); // Checks that run on the raw string, after this one, match only the diff --git a/src/docs/src/Hosting.md b/src/docs/src/Hosting.md index 6058024c6..0e724011d 100644 --- a/src/docs/src/Hosting.md +++ b/src/docs/src/Hosting.md @@ -191,6 +191,7 @@ These hosting features are supported out of the box when using Puter.js: - **[`puter.hosting.delete()`](/Hosting/delete/)** - Delete a hosting deployment - **[`puter.hosting.update()`](/Hosting/update/)** - Update hosting settings - **[`puter.hosting.get()`](/Hosting/get/)** - Get information about a specific deployment +- **[`puter.hosting.attach()`](/Hosting/attach/)** - Let the user connect their own domain to a website ## Examples diff --git a/src/docs/src/Hosting/attach.md b/src/docs/src/Hosting/attach.md new file mode 100644 index 000000000..8cf0d5fa3 --- /dev/null +++ b/src/docs/src/Hosting/attach.md @@ -0,0 +1,70 @@ +--- +title: puter.hosting.attach() +description: Let the user connect one of their own domains to a website hosted on Puter. +platforms: [websites, apps] +--- + +Opens a Puter dialog where the user connects a domain they own, such as `www.example.com`, to one of their websites. In the dialog, the user can pick a domain already on their account or add a new one. A domain the user picks that currently serves another of their websites moves to this one. + +When the user adds a new domain, the dialog shows the DNS records to set at their DNS provider. The domain starts serving the website once those records are in place, usually within a few minutes. + +Your app doesn't need any permission to call this method. The user makes the choice in the dialog, and your app only receives the domain they chose. Custom domains are billed to the user's Puter account, and the dialog shows the price before the user adds one. + +Inside Puter, the dialog is rendered by the desktop environment. On a website, a puter.com popup hosts the dialog and signs the user in first if needed. + +## Syntax + +```js +puter.hosting.attach(subdomain) +``` + +## Parameters + +#### `subdomain` (String) (required) + +The name of the website to connect a domain to, such as `example` for `example.puter.site`. The website must belong to the signed-in user. + +## Return value + +A `Promise` that resolves to a [`CustomDomain`](/Objects/customdomain/) object for the domain the user chose, or `null` if the user closed the dialog without choosing one. + +A newly added domain resolves with `status` set to `pending`. Its `records` list the DNS records the user still has to set. + +On a website that is cross-origin isolated, the popup can't send its result back to your page, so the promise resolves `null` even if the user goes on to connect a domain in the popup. + +The promise rejects with an error whose `code` is one of the following: + +- `invalid_argument` when `subdomain` is missing or empty. +- `not_available` in an environment that can't show a dialog, such as Node.js or a worker. +- `not_supported` inside a version of the Puter desktop that doesn't have the dialog. + +## Examples + +Connect a custom domain to a website + +```html;hosting-attach + + + + + + + +``` diff --git a/src/docs/src/Objects.md b/src/docs/src/Objects.md index 8213f9e36..36df61a4d 100644 --- a/src/docs/src/Objects.md +++ b/src/docs/src/Objects.md @@ -12,6 +12,7 @@ Various object types and classes that represent different entities in the Puter - **[ChatResponse](/Objects/chatresponse/)** - Represents an AI chat response - **[ChatResponseChunk](/Objects/chatresponsechunk/)** - Represents a chunk of streaming chat response data - **[CreateAppResult](/Objects/createappresult/)** - Represents the result of creating an app +- **[CustomDomain](/Objects/customdomain/)** - Represents a domain connected to a website - **[DetailedAppUsage](/Objects/detailedappusage/)** - Represents detailed resource usage statistics for a specific application - **[EmailListPage](/Objects/emaillistpage/)** - Represents one page of mailbox listing results - **[EmailMessage](/Objects/emailmessage/)** - Represents a fully parsed message from the user's mailbox diff --git a/src/docs/src/Objects/customdomain.md b/src/docs/src/Objects/customdomain.md new file mode 100644 index 000000000..a7950774c --- /dev/null +++ b/src/docs/src/Objects/customdomain.md @@ -0,0 +1,54 @@ +--- +title: CustomDomain +description: The CustomDomain object containing the details of a domain connected to a website. +--- + +The `CustomDomain` object describes a domain the user owns that serves one of their websites hosted on Puter. The [`puter.hosting.attach()`](/Hosting/attach/) method resolves to it. + +## Attributes + +#### `uuid` (String) + +The unique identifier of the custom domain. + +#### `hostname` (String) + +The domain, such as `www.example.com`. + +#### `site` (String) + +The name of the website the domain serves, such as `example` for `example.puter.site`. + +#### `status` (String) + +The state of the domain. It's one of the following: + +- `pending` until the DNS records are set and the domain is verified. +- `active` once the domain serves the website. +- `failed` when the domain can't be verified. The `statusDetail` attribute says why. + +#### `statusDetail` (String | null) + +Why the domain isn't active yet, when that's known. + +#### `isApex` (Boolean) + +`true` when the hostname is a root domain such as `example.com`. A root domain can't use a regular `CNAME` record, so its DNS provider has to support `CNAME` flattening, `ALIAS`, or `ANAME` records. + +#### `records` (Array) + +The DNS records to set at the domain's DNS provider. Each record is an object with these attributes: + +- `type` (String): `CNAME` or `TXT`. +- `name` (String): the record's name. +- `value` (String): the record's value. +- `purpose` (String): `routing` for the record that sends traffic to the website, `ownership` for the record that proves the user owns the domain, or `certificate` for a record that speeds up issuing the HTTPS certificate. +- `required` (Boolean): `true` for the `routing` and `ownership` records, which the user has to set. A `certificate` record is optional. + +#### `createdAt` (Number) + +When the domain was added, in milliseconds since the Unix epoch. + +#### `activatedAt` (Number | null) + +When the domain became active, in milliseconds since the Unix epoch, or `null` if it isn't active. diff --git a/src/docs/src/examples.js b/src/docs/src/examples.js index 11fb22e38..fc2c7b400 100644 --- a/src/docs/src/examples.js +++ b/src/docs/src/examples.js @@ -775,6 +775,12 @@ const examples = [ slug: 'hosting-get', source: '/playground/examples/hosting-get.html', }, + { + title: 'Connect a custom domain to a website', + description: 'Let the user connect their own domain to a website with Puter.js hosting API. Run and experiment with this example directly in the playground.', + slug: 'hosting-attach', + source: '/playground/examples/hosting-attach.html', + }, { title: 'Publish a portfolio page', description: 'Publish a page built from form input as a public website with Puter.js hosting API. Run and modify this example directly in your browser.', diff --git a/src/docs/src/playground/examples/hosting-attach.html b/src/docs/src/playground/examples/hosting-attach.html new file mode 100644 index 000000000..508109292 --- /dev/null +++ b/src/docs/src/playground/examples/hosting-attach.html @@ -0,0 +1,24 @@ + + + + + + + diff --git a/src/docs/src/sidebar.js b/src/docs/src/sidebar.js index fe7712226..e487b6793 100755 --- a/src/docs/src/sidebar.js +++ b/src/docs/src/sidebar.js @@ -648,6 +648,14 @@ let sidebar = [ source: '/Hosting/get.md', path: '/Hosting/get', }, + { + title: 'attach()', + page_title: 'puter.hosting.attach()', + title_tag: 'puter.hosting.attach()', + icon: '/assets/img/function.svg', + source: '/Hosting/attach.md', + path: '/Hosting/attach', + }, ], }, { @@ -1444,6 +1452,13 @@ let sidebar = [ source: '/Objects/speech2txtresult.md', path: '/Objects/speech2txtresult', }, + { + title: 'CustomDomain', + title_tag: 'CustomDomain', + icon: '/assets/img/object.svg', + source: '/Objects/customdomain.md', + path: '/Objects/customdomain', + }, { title: 'Subdomain', title_tag: 'Subdomain', diff --git a/src/gui/src/IPC.js b/src/gui/src/IPC.js index e944e3875..c82fbf5f0 100644 --- a/src/gui/src/IPC.js +++ b/src/gui/src/IPC.js @@ -29,6 +29,7 @@ import UIPopover from './UI/UIPopover.js'; import UIPrompt from './UI/UIPrompt.js'; import UIWindow from './UI/UIWindow.js'; import UIWindowAppFeedback from './UI/UIWindowAppFeedback.js'; +import UIWindowAttachCustomDomain from './UI/UIWindowAttachCustomDomain.js'; import UIWindowColorPicker from './UI/UIWindowColorPicker.js'; import UIWindowEmailConfirmationRequired from './UI/UIWindowEmailConfirmationRequired.js'; import UIWindowFontPicker from './UI/UIWindowFontPicker.js'; @@ -51,6 +52,8 @@ window.ipc_handlers = {}; // handler below): one dialog at a time, and nothing else — reopening is always // allowed. See feedbackDialogGuard.js. const feedback_dialog_guard = createFeedbackDialogGuard(); +// Same one-at-a-time rule for the custom domain dialog. +const custom_domain_dialog_guard = createFeedbackDialogGuard(); /** * In Puter, apps are loaded in iframes and communicate with the graphical user interface (GUI), and each other, using the postMessage API. @@ -1490,6 +1493,56 @@ const ipc_listener = async (event, handled) => { } } //-------------------------------------------------------- + // attachCustomDomain + //-------------------------------------------------------- + else if ( event.data.msg === 'attachCustomDomain' ) { + // Always answer, as for showFeedbackDialog: the SDK promise only + // settles on a reply. + const respond = (domain) => { + target_iframe?.contentWindow?.postMessage({ + msg: 'customDomainAttached', + domain: domain ?? null, + original_msg_id: msg_id, + }, '*'); + }; + + const subdomain = typeof event.data.subdomain === 'string' ? event.data.subdomain : ''; + if ( ! subdomain || ! custom_domain_dialog_guard.mayOpen() ) { + respond(null); + return; + } + + custom_domain_dialog_guard.markOpened(); + try { + try { + if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) ) { + respond(null); + return; + } + } catch ( e ) { + console.error('IPC attachCustomDomain: auth gate failed', e); + respond(null); + return; + } + + let domain = null; + try { + // The requester shown is the app's registered name from the + // GUI's own records; its window title is app-controlled. + domain = await UIWindowAttachCustomDomain({ + subdomain, + requester: app_name, + }); + } catch ( e ) { + console.error('IPC attachCustomDomain failed', e); + } + respond(domain); + $(target_iframe).get(0)?.focus({ preventScroll: true }); + } finally { + custom_domain_dialog_guard.markClosed(); + } + } + //-------------------------------------------------------- // showFontPicker //-------------------------------------------------------- else if ( event.data.msg === 'showFontPicker' ) { diff --git a/src/gui/src/UI/UIPermissionDialog.js b/src/gui/src/UI/UIPermissionDialog.js index 00fce1104..5732ec3ae 100644 --- a/src/gui/src/UI/UIPermissionDialog.js +++ b/src/gui/src/UI/UIPermissionDialog.js @@ -712,6 +712,10 @@ export async function get_permission_description (permission, options = {}) { return { html: i18n('perm_events_background'), icon: 'zap' }; } + if ( permission === 'custom-domains:manage' ) { + return { html: i18n('perm_custom_domains_manage'), icon: 'globe' }; + } + const parts = split_permission(permission); // The grant route refuses any other spelling of the same parts. if ( parts.map(escape_permission_component).join(':') !== permission ) return null; diff --git a/src/gui/src/UI/UIWindowAttachCustomDomain.js b/src/gui/src/UI/UIWindowAttachCustomDomain.js new file mode 100644 index 000000000..aa09bcf05 --- /dev/null +++ b/src/gui/src/UI/UIWindowAttachCustomDomain.js @@ -0,0 +1,456 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +let attach_modal_seq = 0; + +/** API error code → message key; anything unlisted gets the generic error. */ +const ERROR_KEYS = { + invalid_hostname: 'custom_domain_error_invalid', + custom_domain_taken: 'custom_domain_error_taken', + custom_domain_limit_reached: 'custom_domain_error_limit', + custom_domain_rejected: 'custom_domain_error_rejected', + site_not_found: 'custom_domain_error_site', + custom_domain_not_found: 'custom_domain_error_not_found', + custom_domains_not_enabled: 'custom_domain_not_enabled', + custom_domains_permission_required: 'custom_domain_error_generic', +}; + +const format_price = (pricing) => { + if ( ! pricing || typeof pricing.amount !== 'number' ) return null; + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: pricing.currency || 'USD', + minimumFractionDigits: 2, + }).format(pricing.amount); + } catch { + return null; + } +}; + +/** + * "Connect a custom domain to this site" modal, behind + * `puter.hosting.attach()`. The user picks one of their domains (moving it + * from another site if need be) or adds one; the caller learns only the + * domain that ends up chosen. + * + * Built on the app feedback modal's shell (`.app-feedback-*`) so it renders + * the same on the desktop and in the standalone popup. Talks to the + * `/custom-domains` API with the GUI's own session: the requesting app never + * holds a token that can manage domains. + * + * @param {{ subdomain: string, requester?: string }} options `requester` is + * the app title or opener origin shown to the user, as the GUI resolved it. + * @returns {Promise} The chosen domain as the API returns it, or + * null when the user closes the dialog first. Never rejects. + */ +async function UIWindowAttachCustomDomain (options) { + options = options ?? {}; + const subdomain = String(options.subdomain ?? ''); + const site_host = `${subdomain}.${window.hosting_domain}`; + + return new Promise((resolve) => { + const modal_id = `custom-domain-${++attach_modal_seq}`; + const authToken = puter.authToken ?? window.auth_token; + let busy = false; + let closed = false; + // A domain added in this dialog is the answer even if the user then + // closes it: it exists, and is billed, either way. + let chosen = null; + + const titleId = `${modal_id}-title`; + const h = ` + + `; + + const $overlay = $(h); + $('body').append($overlay); + requestAnimationFrame(() => $overlay.addClass('app-feedback-open')); + + const close = () => { + if ( closed ) return; + closed = true; + $(document).off(`keydown.${modal_id}`); + $overlay.removeClass('app-feedback-open'); + setTimeout(() => $overlay.remove(), 160); + resolve(chosen); + }; + const finish = (domain) => { + chosen = domain; + close(); + }; + + const showPane = (pane) => { + $overlay.find('.custom-domain-pane').hide(); + $overlay.find('.app-feedback-error').hide(); + $overlay.find(`.custom-domain-pane[data-pane="${pane}"]`).show(); + }; + // Plain strings into `.text()`: i18n's HTML encoding would show up + // literally. + const text = (key) => i18n(key, [], false); + const showUnavailable = (key) => { + $overlay.find('.app-feedback-unavailable-message').text(text(key)); + showPane('unavailable'); + }; + const showError = (pane, message) => { + $overlay.find(`.custom-domain-pane[data-pane="${pane}"] .app-feedback-error`) + .text(message).show(); + }; + const setBusy = (value) => { + busy = value; + $overlay.find('button').prop('disabled', value); + if ( ! value ) updateAddButton(); + }; + + const api = async (method, path = '', body) => { + const res = await fetch(`${window.api_origin}/custom-domains${path}`, { + method, + headers: { + 'Authorization': `Bearer ${authToken}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + let data = null; + try { + data = await res.json(); + } catch { + // Non-JSON body; callers go by the status. + } + return { status: res.status, ok: res.ok, data }; + }; + // A known code gets our wording; anything else shows what the server + // said, which beats a generic message when something is misconfigured. + const errorFor = (res) => { + if ( res.status === 402 ) return text('custom_domain_error_plan'); + if ( res.status === 429 ) return text('custom_domain_error_rate_limited'); + const key = ERROR_KEYS[res.data?.code]; + if ( key ) return text(key); + if ( typeof res.data?.message === 'string' && res.data.message ) { + return res.data.message; + } + return `${text('custom_domain_error_generic')} (HTTP ${res.status})`; + }; + + // Escape, the backdrop and the close buttons all dismiss, except + // while a request is in flight: closing then would report no domain + // for one that may still land server-side. + $(document).on(`keydown.${modal_id}`, (e) => { + if ( e.key === 'Escape' && ! busy ) close(); + }); + $overlay.on('mousedown', (e) => { + // Same reason as the feedback modal: keep the press from focusing + // the app window underneath. + window.mouseover_window = undefined; + if ( e.target === $overlay.get(0) && ! busy ) close(); + }); + $overlay.find('.app-feedback-x, .custom-domain-close').on('click', () => { + if ( ! busy ) close(); + }); + + // -- List --------------------------------------------------------- + + const renderList = (items) => { + const $list = $overlay.find('.custom-domain-list').empty(); + $overlay.find('.custom-domain-empty').toggle(items.length === 0); + for ( const item of items ) { + const elsewhere = item.site !== subdomain; + const $row = $(` +
  • +
    +
    +
    +
    + +
  • + `); + $row.find('.custom-domain-row-host').text(item.hostname); + $row.find('.custom-domain-row-meta').text(elsewhere + ? i18n('custom_domain_serves', [`${item.site}.${window.hosting_domain}`], false) + : i18n('custom_domain_serves_this', [], false)); + $row.find('.custom-domain-use').on('click', () => use(item, $row)); + $list.append($row); + } + }; + + const use = async (item, $row) => { + if ( busy ) return; + if ( item.site === subdomain ) { + finish(item); + return; + } + // Moving takes the domain off another site, so say so first. + if ( ! $row.hasClass('custom-domain-confirming') ) { + $overlay.find('.custom-domain-confirming').removeClass('custom-domain-confirming') + .find('.custom-domain-use').text(text('custom_domain_use')); + $row.addClass('custom-domain-confirming'); + $row.find('.custom-domain-row-meta').text(i18n('custom_domain_move_confirm', [ + `${item.site}.${window.hosting_domain}`, + site_host, + ], false)); + $row.find('.custom-domain-use').text(text('custom_domain_move')); + return; + } + setBusy(true); + try { + const res = await api('PATCH', `/${encodeURIComponent(item.uuid)}`, { site: subdomain }); + if ( ! res.ok ) { + showError('list', errorFor(res)); + return; + } + finish(res.data); + } catch ( e ) { + console.error('custom-domain: move failed', e); + showError('list', text('custom_domain_error_generic')); + } finally { + setBusy(false); + } + }; + + // -- Add ---------------------------------------------------------- + + const $input = $overlay.find('.custom-domain-input'); + const updateAddButton = () => { + $overlay.find('.custom-domain-add').prop('disabled', busy || String($input.val() || '').trim() === ''); + }; + // A claim is for one hostname; editing it starts over. + const hideClaim = () => { + $overlay.find('.custom-domain-claim').hide(); + $overlay.find('.custom-domain-add').text(text('custom_domain_add')); + }; + const showClaim = (record) => { + $overlay.find('.custom-domain-claim-name').text(record.name); + $overlay.find('.custom-domain-claim-value').text(record.value); + $overlay.find('.custom-domain-claim').show(); + $overlay.find('.custom-domain-add').text(text('custom_domain_verify_and_add')); + }; + $input.on('input', () => { + hideClaim(); + updateAddButton(); + }); + $overlay.find('.custom-domain-claim .custom-domain-copy') + .attr('title', text('copy')) + .on('click', function () { + navigator.clipboard?.writeText($(this).text()).catch(() => {}); + }); + + $overlay.find('.custom-domain-to-add').on('click', () => { + showPane('add'); + $input.get(0)?.focus({ preventScroll: true }); + }); + $overlay.find('.custom-domain-back').on('click', () => { + if ( ! busy ) showPane('list'); + }); + + $overlay.find('form[data-pane="add"]').on('submit', async (e) => { + e.preventDefault(); + const hostname = String($input.val() || '').trim(); + if ( ! hostname || busy ) return; + setBusy(true); + try { + const res = await api('POST', '', { site: subdomain, hostname }); + if ( res.data?.code === 'custom_domain_claim_required' && res.data.verification ) { + // Shown the first time; a retry that still can't see the + // record says so. + const retry = $overlay.find('.custom-domain-claim').is(':visible'); + showClaim(res.data.verification); + if ( retry ) showError('add', text('custom_domain_claim_not_found')); + return; + } + if ( ! res.ok ) { + showError('add', errorFor(res)); + return; + } + hideClaim(); + chosen = res.data; + renderRecords(res.data); + showPane('records'); + } catch ( err ) { + console.error('custom-domain: add failed', err); + showError('add', text('custom_domain_error_generic')); + } finally { + setBusy(false); + } + }); + + // -- Records -------------------------------------------------------- + + const renderRecords = (domain) => { + const key = domain.status === 'active' + ? 'custom_domain_status_active' + : domain.status === 'failed' + ? 'custom_domain_status_failed' + : 'custom_domain_status_pending'; + const $status = $overlay.find('.custom-domain-status') + .html(i18n(key, [domain.hostname])); + if ( domain.statusDetail && domain.status !== 'active' ) { + $status.append($('').text(domain.statusDetail)); + } + $overlay.find('.custom-domain-apex').toggle(domain.isApex === true); + + const $tbody = $overlay.find('.custom-domain-records tbody').empty(); + for ( const record of domain.records ?? [] ) { + const $tr = $(` + + + + + + `); + $tr.find('.custom-domain-record-type').text(record.required + ? record.type + : `${record.type} ${i18n('custom_domain_optional', [], false)}`); + const [$name, $value] = $tr.find('code').toArray().map((el) => $(el)); + $name.text(record.name); + $value.text(record.value); + $tbody.append($tr); + } + $tbody.find('.custom-domain-copy').attr('title', i18n('copy', [], false)).on('click', function () { + navigator.clipboard?.writeText($(this).text()).catch(() => {}); + }); + }; + + $overlay.find('.custom-domain-check').on('click', async () => { + if ( busy || ! chosen ) return; + setBusy(true); + try { + const res = await api('GET', `/${encodeURIComponent(chosen.uuid)}`); + if ( ! res.ok ) { + showError('records', errorFor(res)); + return; + } + // A domain that is no longer pending comes back with only its + // routing record; keep the full set shown when it was added. + chosen = { ...res.data, records: res.data.records?.length > 1 ? res.data.records : chosen.records }; + renderRecords(chosen); + } catch ( e ) { + console.error('custom-domain: status check failed', e); + showError('records', text('custom_domain_error_generic')); + } finally { + setBusy(false); + } + }); + $overlay.find('.custom-domain-done').on('click', () => { + if ( ! busy ) close(); + }); + + // -- Load ------------------------------------------------------------- + + (async () => { + try { + const res = await api('GET'); + if ( closed ) return; + // No custom domain API on this Puter: say so rather than show + // an empty list the user can't add to. + if ( res.status === 404 ) { + showUnavailable('custom_domain_unavailable'); + return; + } + if ( res.data?.code === 'custom_domains_not_enabled' ) { + showUnavailable('custom_domain_not_enabled'); + return; + } + if ( ! res.ok ) { + showUnavailable('custom_domain_error_generic'); + return; + } + renderList(res.data?.items ?? []); + const price = format_price(res.data?.pricing); + if ( price ) { + $overlay.find('.custom-domain-price').text(i18n('custom_domain_price_note', [price], false)).show(); + } + showPane('list'); + } catch ( e ) { + console.error('custom-domain: list failed', e); + if ( ! closed ) showUnavailable('custom_domain_error_generic'); + } + })(); + }); +} + +export default UIWindowAttachCustomDomain; diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index 6e723ba56..45dcdd810 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -2006,6 +2006,136 @@ input.myapps-group-name:focus { } } +/* Custom domain modal (puter.hosting.attach), on the app feedback shell. */ +.custom-domain-modal { + width: min(520px, 100%); +} +.custom-domain-intro, +.custom-domain-requester { + margin: 0 0 12px; + font-size: 14px; + line-height: 1.45; + word-break: break-word; +} +.custom-domain-requester { + margin-top: -6px; + font-size: 12px; + color: var(--afb-muted); +} +.custom-domain-list { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid var(--afb-border); + border-radius: 10px; + overflow: hidden; +} +.custom-domain-list:empty { + display: none; +} +.custom-domain-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; +} +.custom-domain-row + .custom-domain-row { + border-top: 1px solid var(--afb-border); +} +.custom-domain-row-main { + flex: 1; + min-width: 0; +} +.custom-domain-row-host { + font-size: 14px; + font-weight: 600; + word-break: break-all; +} +.custom-domain-row-meta { + font-size: 12px; + color: var(--afb-muted); + margin-top: 2px; +} +.custom-domain-confirming .custom-domain-row-meta { + color: var(--afb-fg); +} +.custom-domain-row .app-feedback-btn { + flex: none; + padding: 6px 14px; +} +.custom-domain-empty { + margin: 4px 0 0; + font-size: 14px; + color: var(--afb-muted); +} +.custom-domain-label { + display: block; + font-size: 13px; + font-weight: 500; + margin-bottom: 6px; +} +.custom-domain-input { + display: block; + width: 100%; + box-sizing: border-box; + padding: 9px 12px; + font: inherit; + font-size: 14px; + color: var(--afb-fg); + background: var(--afb-field-bg); + border: 1px solid var(--afb-field-border); + border-radius: 8px; +} +.custom-domain-input:focus { + outline: none; + border-color: #088ef0; + box-shadow: 0 0 0 3px rgba(8, 142, 240, 0.18); +} +.custom-domain-status { + margin: 0 0 8px; + font-size: 14px; + line-height: 1.45; + word-break: break-word; +} +.custom-domain-status-detail { + display: block; + margin-top: 4px; + font-size: 12px; + color: var(--afb-muted); +} +.custom-domain-records { + width: 100%; + margin-top: 10px; + border-collapse: collapse; + font-size: 12px; + table-layout: fixed; +} +.custom-domain-records th { + text-align: left; + font-weight: 500; + color: var(--afb-muted); + padding: 0 6px 6px 0; +} +.custom-domain-records th:first-child { + width: 86px; +} +.custom-domain-records td { + padding: 6px 6px 6px 0; + border-top: 1px solid var(--afb-border); + vertical-align: top; +} +.custom-domain-copy { + display: block; + word-break: break-all; + cursor: copy; + border-radius: 4px; +} +@media (hover: hover) { + .custom-domain-copy:hover { + background: var(--afb-hover); + } +} + /* Add-an-app chooser (see showAddAppModal): the three ways to get an app, as rows in the same modal shell. min-width gives the two-line rows room without overflowing a narrow phone; the shell's own max-width caps the other end. */ diff --git a/src/gui/src/helpers/launchApp.js b/src/gui/src/helpers/launchApp.js index d3f557f31..f050f55fe 100644 --- a/src/gui/src/helpers/launchApp.js +++ b/src/gui/src/helpers/launchApp.js @@ -595,7 +595,7 @@ const launch_app = async (options) => { // for: such a message is never replied to, and a reply timeout can't // stand in for the check because legitimate replies only arrive when // the user closes the dialog. - iframe_url.searchParams.append('puter.gui_features', 'feedback-dialog'); + iframe_url.searchParams.append('puter.gui_features', 'feedback-dialog,custom-domains'); // Add options.args to URL iframe_url.searchParams.append('puter.args', JSON.stringify(options.args ?? {})); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index be91aedf8..d4db65b77 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -158,6 +158,42 @@ const en = { create_shortcut_s: 'Create Shortcuts', credits: 'Credits', current_password: 'Current Password', + custom_domain_add: 'Add Domain', + custom_domain_add_new: 'Add a New Domain', + custom_domain_apex_hint: 'This is a root domain. Your DNS provider needs to support CNAME flattening, ALIAS, or ANAME records for it. If it doesn\'t, connect www instead and forward the root domain to it.', + custom_domain_claim_c2a: 'Someone else added this domain but never connected it. If it\'s yours, add this record at your DNS provider, then verify. The domain moves to your account.', + custom_domain_claim_not_found: 'The record isn\'t visible yet. DNS changes can take a few minutes; try again shortly.', + custom_domain_check_status: 'Check Status', + custom_domain_error_generic: 'Something went wrong. Please try again.', + custom_domain_error_invalid: 'Enter a domain name like www.example.com.', + custom_domain_error_limit: 'Your account has reached the maximum number of custom domains.', + custom_domain_error_not_found: 'This domain isn\'t on your account anymore. It may have been removed.', + custom_domain_error_plan: 'Custom domains need a paid plan.', + custom_domain_error_rate_limited: 'Too many attempts. Please wait a minute and try again.', + custom_domain_error_rejected: 'That domain can\'t be added. Check the name and try again.', + custom_domain_error_site: 'This website isn\'t on your account.', + custom_domain_error_taken: 'That domain is already connected to a Puter account.', + custom_domain_hostname_label: 'Domain', + custom_domain_intro: 'Choose a domain for %strong%.', + custom_domain_move: 'Move', + custom_domain_move_confirm: 'Moves it from %% to %%.', + custom_domain_not_enabled: 'Custom domains aren\'t available on your account yet.', + custom_domain_none_yet: 'You don\'t have any custom domains yet.', + custom_domain_optional: '(optional)', + custom_domain_price_note: 'Each domain costs %% per month. example.com and www.example.com count as two domains.', + custom_domain_record_type: 'Type', + custom_domain_record_value: 'Value', + custom_domain_records_c2a: 'Add these records at your DNS provider. The domain starts working a few minutes after they\'re in place.', + custom_domain_requested_by: 'Requested by %strong%', + custom_domain_serves: 'Serving %%', + custom_domain_serves_this: 'Serving this website', + custom_domain_status_active: '%strong% is connected.', + custom_domain_status_failed: '%strong% couldn\'t be verified.', + custom_domain_status_pending: '%strong% is waiting for its DNS records.', + custom_domain_title: 'Connect a Custom Domain', + custom_domain_unavailable: 'Custom domains aren\'t available on this Puter.', + custom_domain_use: 'Use', + custom_domain_verify_and_add: 'Verify and Add', cut: 'Cut', client_information: 'Client Information', close_all: 'Close All', @@ -1006,6 +1042,7 @@ const en = { 'perm_kv_share_manage_read': "let other Puter users it picks read the {{region}} section of {{app}}'s data in your account", 'perm_kv_share_manage_write': "let other Puter users it picks read, change and delete the {{region}} section of {{app}}'s data in your account", 'perm_events_background': 'run in the background when your files or data change, even while it is closed, with those runs billed to your account', + 'perm_custom_domains_manage': 'add, move and remove the custom domains on your account, including ones that cost money', 'perm_dialog_wants_to': 'wants permission to', 'perm_dialog_footnote': 'You can change this anytime in Settings.', 'perm_dialog_error': 'Something went wrong. Please try again.', diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 002050fcf..3661ebc09 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -24,6 +24,7 @@ import UIComponentWindow from './UI/UIComponentWindow.js'; import UIDesktop from './UI/UIDesktop.js'; import UIWindow from './UI/UIWindow.js'; import UIWindowAppFeedback from './UI/UIWindowAppFeedback.js'; +import UIWindowAttachCustomDomain from './UI/UIWindowAttachCustomDomain.js'; import UIWindowAuthMe from './UI/UIWindowAuthMe.js'; import UIWindowChangeUsername from './UI/UIWindowChangeUsername.js'; import UIWindowCopyToken from './UI/UIWindowCopyToken.js'; @@ -912,6 +913,60 @@ const postAuthActions = async (action) => { window.open('', '_self').close(); } } + + // ------------------------------------------------------------------------------------- + // Action: attach-custom-domain — puter.hosting.attach() on a website. Hosts the custom + // domain dialog for `subdomain` and posts the chosen domain back to the opener. + // ------------------------------------------------------------------------------------- + if ( action === 'attach-custom-domain' ) { + const msg_id = window.url_query_params.get('msg_id'); + const subdomain = window.url_query_params.get('subdomain'); + // Browser-attested only: the domain goes to this origin and nowhere + // else, and the dialog names it to the user as the requester. + const origin = window.openerOrigin; + let target_origin = null; + try { + target_origin = origin ? new URL(origin).origin : null; + } catch (e) { + console.error('attach-custom-domain: unusable origin', origin); + } + + let domain = null; + try { + if ( ! target_origin ) { + throw new Error('no opener origin; not prompting'); + } + if ( ! subdomain ) { + throw new Error('no subdomain'); + } + domain = await UIWindowAttachCustomDomain({ + subdomain, + requester: target_origin, + }); + } catch (e) { + console.error('attach-custom-domain action failed', e); + } + + // Without an attested origin there is no safe place to send a domain; + // the requester then sees the popup close and resolves null. + if ( target_origin ) { + const messageTarget = window.embedded_in_popup ? window.opener : window.parent; + try { + messageTarget?.postMessage({ + msg: 'customDomainAttached', + domain, + original_msg_id: msg_id, + }, target_origin); + } catch (e) { + console.error('attach-custom-domain: could not answer the requester', e); + } + } + + if ( window.embedded_in_popup ) { + window.close(); + window.open('', '_self').close(); + } + } }; const launch_services = async function (options) { diff --git a/src/gui/src/util/popupAuth.js b/src/gui/src/util/popupAuth.js index 7753cc138..079707365 100644 --- a/src/gui/src/util/popupAuth.js +++ b/src/gui/src/util/popupAuth.js @@ -34,7 +34,7 @@ */ /** Popup actions that exist to answer a question, not to authenticate. */ -const NON_AUTH_POPUP_ACTIONS = new Set(['request-permission', 'send-feedback']); +const NON_AUTH_POPUP_ACTIONS = new Set(['request-permission', 'send-feedback', 'attach-custom-domain']); /** * Popup actions that exist to sign the user in to the opener, besides a plain diff --git a/src/gui/src/util/popupAuth.test.js b/src/gui/src/util/popupAuth.test.js index ab9e08063..878674c77 100644 --- a/src/gui/src/util/popupAuth.test.js +++ b/src/gui/src/util/popupAuth.test.js @@ -54,7 +54,7 @@ describe('deliversTokenAtBoot', () => { }); it('keeps withholding the token from the popups that answer a question', () => { - for ( const action of ['request-permission', 'send-feedback'] ) { + for ( const action of ['request-permission', 'send-feedback', 'attach-custom-domain'] ) { expect(deliversTokenAtBoot(action)).toBe(false); expect(defersTokenToOpener(action)).toBe(false); } @@ -74,6 +74,11 @@ describe('deliversTokenToOpener', () => { expect(deliversTokenToOpener('send-feedback')).toBe(false); }); + it('withholds the token from a custom domain popup', () => { + // The site gets the one domain the user chose, not their account. + expect(deliversTokenToOpener('attach-custom-domain')).toBe(false); + }); + it('delivers the token for the sign-in flows that exist to authenticate', () => { // `undefined` is a plain sign-in popup, which carries no action. for ( const action of [undefined, 'sign-in'] ) { @@ -120,6 +125,12 @@ describe('runsUserAppTokenExchange', () => { expect(runsUserAppTokenExchange('send-feedback')).toBe(false); }); + it('skips the exchange for a custom domain popup', () => { + // The dialog calls the API with the GUI's own session; connecting the + // site to the account would grant it nothing it needs. + expect(runsUserAppTokenExchange('attach-custom-domain')).toBe(false); + }); + it('runs it for sign-in, the pickers, and the permission prompt', () => { // request-permission keeps the exchange: the app row it bootstraps is // what a grant is written against. diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts index 0bf6fe914..93044ab01 100644 --- a/src/puter-js/index.d.ts +++ b/src/puter-js/index.d.ts @@ -175,7 +175,11 @@ export type { } from './types/modules/FSItem.js'; // -- puter.hosting -- -export type { Subdomain } from './types/modules/hosting/types.js'; +export type { + CustomDomain, + CustomDomainRecord, + Subdomain, +} from './types/modules/hosting/types.js'; // -- puter.kv -- export type { diff --git a/src/puter-js/src/modules/UI.attachCustomDomain.test.js b/src/puter-js/src/modules/UI.attachCustomDomain.test.js new file mode 100644 index 000000000..f84ffea79 --- /dev/null +++ b/src/puter-js/src/modules/UI.attachCustomDomain.test.js @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Popups are opened through the shared helper; stub it so a test can see the +// URL and hand back a window it controls. +const opened = []; +vi.mock('../lib/auth-popup.js', async (importOriginal) => ({ + ...(await importOriginal()), + hasUserActivation: () => true, + openAuthPopup: (url, name) => { + const popup = { url, name, closed: false }; + opened.push(popup); + return popup; + }, +})); + +const { UIModule } = await import('./UI.js'); + +const GUI_ORIGIN = 'https://puter.com'; +const DOMAIN = { + uuid: 'cd-1', + hostname: 'www.example.com', + site: 'mysite', + status: 'pending', +}; + +let messageHandlers; +let parentWindow; + +const makeUI = (env) => + new UIModule({ + env, + authToken: 'token-1', + APIOrigin: 'https://api.test', + appID: 'app-1', + util: {}, + }, { appInstanceID: 'instance-1' }); + +const deliver = async (event) => { + for ( const handler of [...messageHandlers] ) await handler(event); +}; + +const PENDING = Symbol('pending'); +const settled = (promise) => Promise.race([promise, Promise.resolve(PENDING)]); + +const attachCalls = () => + parentWindow.postMessage.mock.calls.filter(([m]) => m?.msg === 'attachCustomDomain'); + +beforeEach(() => { + vi.useFakeTimers(); + messageHandlers = []; + opened.length = 0; + parentWindow = { postMessage: vi.fn() }; + globalThis.window = { + parent: parentWindow, + focus: vi.fn(), + addEventListener: (name, handler) => { + if ( name === 'message' ) messageHandlers.push(handler); + }, + removeEventListener: (name, handler) => { + const i = messageHandlers.indexOf(handler); + if ( name === 'message' && i !== -1 ) messageHandlers.splice(i, 1); + }, + }; + globalThis.document = { addEventListener: vi.fn() }; + globalThis.open = vi.fn(); + globalThis.puter = { defaultGUIOrigin: GUI_ORIGIN }; + globalThis.location = { search: '?puter.gui_features=feedback-dialog,custom-domains' }; +}); + +afterEach(() => { + vi.useRealTimers(); + for ( const name of ['window', 'document', 'open', 'puter', 'location'] ) { + delete globalThis[name]; + } +}); + +describe('attachCustomDomain_ inside the Puter desktop', () => { + it('asks the desktop and resolves the domain the user chose', async () => { + const ui = makeUI('app'); + const promise = ui.attachCustomDomain_('mysite'); + + const [[msg]] = attachCalls(); + expect(msg).toMatchObject({ + msg: 'attachCustomDomain', + subdomain: 'mysite', + appInstanceID: 'instance-1', + }); + + await deliver({ + source: parentWindow, + origin: GUI_ORIGIN, + data: { msg: 'customDomainAttached', original_msg_id: msg.uuid, domain: DOMAIN }, + }); + await expect(promise).resolves.toEqual(DOMAIN); + }); + + it('resolves null when the user closes the dialog', async () => { + const ui = makeUI('app'); + const promise = ui.attachCustomDomain_('mysite'); + const [[msg]] = attachCalls(); + + await deliver({ + source: parentWindow, + origin: GUI_ORIGIN, + data: { msg: 'customDomainAttached', original_msg_id: msg.uuid, domain: null }, + }); + await expect(promise).resolves.toBeNull(); + }); + + it('ignores a reply from anything but the desktop', async () => { + const ui = makeUI('app'); + const promise = ui.attachCustomDomain_('mysite'); + const [[msg]] = attachCalls(); + + await deliver({ + source: {}, + origin: 'https://evil.example', + data: { msg: 'customDomainAttached', original_msg_id: msg.uuid, domain: DOMAIN }, + }); + expect(await settled(promise)).toBe(PENDING); + }); + + it('rejects on a desktop without the dialog instead of hanging', async () => { + globalThis.location = { search: '?puter.gui_features=feedback-dialog' }; + const ui = makeUI('app'); + + await expect(ui.attachCustomDomain_('mysite')).rejects.toMatchObject({ + code: 'not_supported', + }); + expect(attachCalls()).toHaveLength(0); + }); +}); + +describe('attachCustomDomain_ on a third-party site', () => { + it('opens the GUI action popup for the subdomain and resolves its answer', async () => { + const ui = makeUI('web'); + const promise = ui.attachCustomDomain_('mysite'); + + expect(opened).toHaveLength(1); + const url = new URL(opened[0].url); + expect(url.origin).toBe(GUI_ORIGIN); + expect(url.pathname).toBe('/action/attach-custom-domain'); + expect(url.searchParams.get('embedded_in_popup')).toBe('true'); + expect(url.searchParams.get('subdomain')).toBe('mysite'); + const msg_id = url.searchParams.get('msg_id'); + + // Wrong request, wrong window, wrong origin: all ignored. + await deliver({ source: opened[0], origin: GUI_ORIGIN, data: { msg: 'customDomainAttached', original_msg_id: 'other', domain: DOMAIN } }); + await deliver({ source: {}, origin: GUI_ORIGIN, data: { msg: 'customDomainAttached', original_msg_id: msg_id, domain: DOMAIN } }); + await deliver({ source: opened[0], origin: 'https://evil.example', data: { msg: 'customDomainAttached', original_msg_id: msg_id, domain: DOMAIN } }); + expect(await settled(promise)).toBe(PENDING); + + await deliver({ source: opened[0], origin: GUI_ORIGIN, data: { msg: 'customDomainAttached', original_msg_id: msg_id, domain: DOMAIN } }); + await expect(promise).resolves.toEqual(DOMAIN); + }); + + it('resolves null once the popup is closed without an answer', async () => { + const ui = makeUI('web'); + const promise = ui.attachCustomDomain_('mysite'); + opened[0].closed = true; + + await vi.advanceTimersByTimeAsync(1200); + await expect(promise).resolves.toBeNull(); + }); +}); + +describe('attachCustomDomain_ where no dialog can be shown', () => { + it.each(['nodejs', 'web-worker', 'service-worker'])('rejects in %s', async (env) => { + const ui = makeUI(env); + await expect(ui.attachCustomDomain_('mysite')).rejects.toMatchObject({ + code: 'not_available', + }); + }); +}); diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js index 238725df7..8fd060d08 100644 --- a/src/puter-js/src/modules/UI.js +++ b/src/puter-js/src/modules/UI.js @@ -2251,8 +2251,33 @@ export class UIModule extends EventListener { if ( this.env !== 'web' ) { return false; } + // The app's identity is deliberately NOT in the popup URL: the GUI + // derives it from the browser-attested opener origin, so a link + // can't open a feedback dialog in another app's name. + const result = await this.#runGuiActionPopup({ + action: 'send-feedback', + replyMsg: 'feedbackDialogClosed', + popupName: 'puter-feedback', + }); + return result?.sent === true; + }; + + /** + * Opens `/action/` on the GUI origin in a popup and waits for + * its single result message. Resolves the message's data, or `null` when + * the popup is blocked, dismissed, or cut off from this page (COOP). + * Never rejects. + * + * @param {Object} opts + * @param {string} opts.action Popup action the GUI handles. + * @param {Record} [opts.params] Extra query parameters. + * @param {string} opts.replyMsg `msg` of the result the GUI posts back. + * @param {string} opts.popupName Prefix for the popup's window name. + * @returns {Promise | null>} + */ + async #runGuiActionPopup ({ action, params = {}, replyMsg, popupName }) { if ( ! globalThis.open || ! globalThis.document ) { - return false; + return null; } // See requestPermission: canonical-to-canonical origin comparison. A @@ -2261,7 +2286,7 @@ export class UIModule extends EventListener { try { gui_origin = new URL(puter.defaultGUIOrigin).origin; } catch (e) { - return false; + return null; } // How long to wait, after the popup is observed closed, for a result @@ -2270,12 +2295,11 @@ export class UIModule extends EventListener { return new Promise((resolve) => { // Unique per request and not reused across page loads — same - // stale-popup collision reasoning as requestPermission. The app's - // identity is deliberately NOT in this URL: the GUI derives it - // from the browser-attested opener origin, so a link can't open a - // feedback dialog in another app's name. + // stale-popup collision reasoning as requestPermission. const msg_id = `${this.#messageID++}-${Math.random().toString(36).slice(2, 10)}`; - const url = `${gui_origin}/action/send-feedback?embedded_in_popup=true&msg_id=${encodeURIComponent(msg_id)}`; + const query = new URLSearchParams({ embedded_in_popup: 'true', msg_id, ...params }); + const url = `${gui_origin}/action/${action}?${query}`; + const name = `${popupName}-${msg_id}`; // Guards against settling more than once across the message, // popup-closed, and dialog-cancel code paths. @@ -2294,11 +2318,11 @@ export class UIModule extends EventListener { consentDialog = null; }; - const settle = (sent) => { + const settle = (data) => { if ( settled ) return; settled = true; cleanup(); - resolve(sent === true); + resolve(data ?? null); }; const messageHandler = (e) => { @@ -2309,28 +2333,26 @@ export class UIModule extends EventListener { if ( e.origin !== gui_origin ) return; if ( popupWindow && e.source !== popupWindow ) return; if ( e.data?.original_msg_id != msg_id ) return; - if ( e.data?.msg !== 'feedbackDialogClosed' ) return; - settle(e.data.sent === true); + if ( e.data?.msg !== replyMsg ) return; + settle(e.data); }; window.addEventListener('message', messageHandler); const watchPopup = (popup) => { if ( settled ) return; if ( ! popup ) { - settle(false); + settle(null); return; } // Pin the expected event.source before anything can return // early. popupWindow = popup; // A severed opener relationship (COOP) means the popup can't - // post the result back and `popup.closed` tells us nothing. - // Unlike a permission grant, a feedback submission can't be - // read back from the server, so the outcome is unknowable - // here: report false now rather than hang. The popup stays - // open — the user can still send their feedback. + // post the result back and `popup.closed` tells us nothing, + // so the outcome is unknowable here: report null now rather + // than hang. The popup stays open for the user. if ( window.crossOriginIsolated || popup.closed ) { - settle(false); + settle(null); return; } checkClosed = setInterval(() => { @@ -2342,37 +2364,77 @@ export class UIModule extends EventListener { // relative to `closed` becoming true — give an in-flight // result its grace period before treating the close as a // dismissal. - setTimeout(() => settle(false), CLOSE_GRACE_MS); + setTimeout(() => settle(null), CLOSE_GRACE_MS); }, 100); }; - // Every path out of here resolves a boolean, so anything that - // throws while launching has to resolve false rather than reject. + // Every path out of here resolves, so anything that throws while + // launching has to resolve null rather than reject. try { if ( hasUserActivation() ) { // Unique window name per request: window.open() reuses a // window with the same name, which would hijack a popup an // earlier, still-pending request is waiting on. - watchPopup(openAuthPopup(url, `puter-feedback-${msg_id}`)); + watchPopup(openAuthPopup(url, name)); } else { // No user gesture: a popup opened now would be blocked. // Show a consent dialog first; the popup is then opened // from the user's click on it. const dialog = new PuterDialog(() => {}, () => {}, { popupURL: url, - popupName: `puter-feedback-${msg_id}`, + popupName: name, onLaunch: (popup) => watchPopup(popup), - onCancel: () => settle(false), + onCancel: () => settle(null), }); consentDialog = dialog; document.body.appendChild(dialog); dialog.open(); } } catch (e) { - settle(false); + settle(null); } }); - }; + } + + /** + * Backs `puter.hosting.attach()`: lets the user pick or add a custom + * domain for `subdomain` in a Puter dialog. The app learns only the + * domain the user chose. + * + * @internal + * @param {string} subdomain + * @returns {Promise} + */ + async attachCustomDomain_ (subdomain) { + if ( this.env === 'app' ) { + // An older desktop has no handler and would never reply; see + // showFeedbackDialog. + const features = new URLSearchParams(globalThis.location?.search ?? '') + .get('puter.gui_features')?.split(',') ?? []; + if ( ! features.includes('custom-domains') ) { + throw new PuterJSError( + 'Custom domains are not available on this Puter desktop', + 'not_supported', + ); + } + const result = await this.#postMessageAsync('attachCustomDomain', { subdomain }); + return result?.domain ?? null; + } + + if ( this.env !== 'web' ) { + throw new PuterJSError( + 'puter.hosting.attach() needs a browser page or a Puter app', + 'not_available', + ); + } + const result = await this.#runGuiActionPopup({ + action: 'attach-custom-domain', + params: { subdomain }, + replyMsg: 'customDomainAttached', + popupName: 'puter-custom-domain', + }); + return /** @type {import('./hosting/types.js').CustomDomain | null} */ (result?.domain ?? null); + } /** * Greys out a menubar item so it cannot be clicked. diff --git a/src/puter-js/src/modules/hosting/attach.js b/src/puter-js/src/modules/hosting/attach.js new file mode 100644 index 000000000..0e82c9a6e --- /dev/null +++ b/src/puter-js/src/modules/hosting/attach.js @@ -0,0 +1,32 @@ +import { PuterJSError } from '../../lib/PuterJSError.js'; +import { normalizeSubdomain } from './lib/args.js'; + +/** @typedef {import('./types.js').CustomDomain} CustomDomain */ + +/** + * @overload + * @param {string} subdomain The site to attach a domain to, e.g. `example` for + * `example.puter.site`. + * @returns {Promise} + */ +/** + * Opens a Puter dialog in which the user picks one of their custom domains, or + * adds a new one, to serve `subdomain`. Needs no permission: the user decides + * in the dialog, and only the domain they chose is returned. + * + * Resolves `null` when the user closes the dialog without choosing. Rejects + * with `invalid_argument` for a missing subdomain, `not_available` where no + * dialog can be shown (node, workers, the Puter desktop itself), and + * `not_supported` inside an older Puter desktop that has no such dialog. + * + * @this {import('./index.js').HostingModule} + * @param {string} subdomain + * @returns {Promise} + */ +export async function attach (subdomain) { + if ( typeof subdomain !== 'string' || subdomain.trim() === '' ) { + throw new PuterJSError('A subdomain is required', 'invalid_argument'); + } + const name = /** @type {string} */ (normalizeSubdomain(subdomain.trim())); + return await this.puter.ui.attachCustomDomain_(name); +} diff --git a/src/puter-js/src/modules/hosting/attach.test.js b/src/puter-js/src/modules/hosting/attach.test.js new file mode 100644 index 000000000..b0ea003aa --- /dev/null +++ b/src/puter-js/src/modules/hosting/attach.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; +import { attach } from './attach.js'; + +const hostingWith = (attachCustomDomain_) => ({ + puter: { ui: { attachCustomDomain_ } }, +}); + +describe('hosting.attach', () => { + it('hands the subdomain to the dialog and returns its answer', async () => { + const dialog = vi.fn(async () => ({ hostname: 'www.example.com' })); + await expect(attach.call(hostingWith(dialog), 'mysite')).resolves.toEqual({ + hostname: 'www.example.com', + }); + expect(dialog).toHaveBeenCalledWith('mysite'); + }); + + it('accepts a full hosted host', async () => { + const dialog = vi.fn(async () => null); + await attach.call(hostingWith(dialog), ' mysite.puter.site '); + expect(dialog).toHaveBeenCalledWith('mysite'); + }); + + it.each([undefined, '', ' ', 42])('rejects %j', async (value) => { + const dialog = vi.fn(); + await expect(attach.call(hostingWith(dialog), value)).rejects.toMatchObject({ + code: 'invalid_argument', + }); + expect(dialog).not.toHaveBeenCalled(); + }); +}); diff --git a/src/puter-js/src/modules/hosting/index.js b/src/puter-js/src/modules/hosting/index.js index 1ad3b53ef..8ba51562f 100644 --- a/src/puter-js/src/modules/hosting/index.js +++ b/src/puter-js/src/modules/hosting/index.js @@ -1,4 +1,5 @@ import { PuterModule } from '../../lib/PuterModule.js'; +import { attach } from './attach.js'; import { create } from './create.js'; import { del } from './delete.js'; import { get } from './get.js'; @@ -24,6 +25,7 @@ export class HostingModule extends PuterModule { update = update; get = get; delete = del; + attach = attach; /** @param {Puter} puter */ constructor (puter) { @@ -32,7 +34,7 @@ export class HostingModule extends PuterModule { const methods = /** @type {Record unknown>} */ ( /** @type {unknown} */ (this) ); - for ( const name of ['list', 'create', 'update', 'get', 'delete'] ) { + for ( const name of ['list', 'create', 'update', 'get', 'delete', 'attach'] ) { methods[name] = methods[name].bind(this); } } diff --git a/src/puter-js/src/modules/hosting/types.js b/src/puter-js/src/modules/hosting/types.js index 1325a4c0e..d6f36729d 100644 --- a/src/puter-js/src/modules/hosting/types.js +++ b/src/puter-js/src/modules/hosting/types.js @@ -11,4 +11,33 @@ * files are stored. */ +/** + * A DNS record the user sets at their DNS provider for a custom domain. + * + * @typedef {Object} CustomDomainRecord + * @property {'CNAME' | 'TXT'} type Record type. + * @property {string} name Record name (host). + * @property {string} value Record value. + * @property {'routing' | 'ownership' | 'certificate'} purpose What the record is for. + * @property {boolean} required `true` for records the user has to set (routing and + * ownership); certificate records only speed issuance up. + */ + +/** + * A domain the user owns, attached to one of their hosted sites. + * + * @typedef {Object} CustomDomain + * @property {string} uuid Unique identifier of the custom domain. + * @property {string} hostname The domain, e.g. `www.example.com`. + * @property {string} site The subdomain it serves, e.g. `example` for `example.puter.site`. + * @property {'pending' | 'active' | 'failed'} status `active` once DNS is set up and the + * domain serves the site; `pending` until then. + * @property {string | null} statusDetail Why the domain is not active yet, when known. + * @property {boolean} isApex Whether the hostname is a root domain (`example.com`), which + * some DNS providers can't point with a CNAME. + * @property {CustomDomainRecord[]} records DNS records to set. + * @property {number} createdAt When the domain was added, in milliseconds since the epoch. + * @property {number | null} activatedAt When the domain went active, if it has. + */ + export {}; diff --git a/src/puter-js/tests/api/suites/hosting.suite.ts b/src/puter-js/tests/api/suites/hosting.suite.ts index 2e889870e..2381126e2 100644 --- a/src/puter-js/tests/api/suites/hosting.suite.ts +++ b/src/puter-js/tests/api/suites/hosting.suite.ts @@ -272,4 +272,16 @@ export default suite('hosting', { 'get of a deleted subdomain should reject', ); }, + + // The dialog needs a page to open: servers and workers can't show one. + 'attach rejects where no dialog can be shown': { + platforms: ['node', 'workerd'], + fn: async (t) => { + const error = (await t.assert.rejects( + () => t.puter.hosting.attach('hosting-suite-attach'), + 'attach without a UI should reject', + )) as { code?: string }; + t.assert.equal(error.code, 'not_available'); + }, + }, }); diff --git a/src/puter-js/tests/e2e/fixtures/attach-custom-domain.html b/src/puter-js/tests/e2e/fixtures/attach-custom-domain.html new file mode 100644 index 000000000..93f569a48 --- /dev/null +++ b/src/puter-js/tests/e2e/fixtures/attach-custom-domain.html @@ -0,0 +1,63 @@ + + + + + puter-js test fixture: hosting.attach + + + +

    puter-js hosting.attach fixture

    +

    Loading puter.js…

    + + + +
    + + + + + + diff --git a/src/puter-js/tests/e2e/specs/attachCustomDomain.spec.js b/src/puter-js/tests/e2e/specs/attachCustomDomain.spec.js new file mode 100644 index 000000000..8cce4d8d9 --- /dev/null +++ b/src/puter-js/tests/e2e/specs/attachCustomDomain.spec.js @@ -0,0 +1,70 @@ +import { test, expect } from '@playwright/test'; +import { registerTestApp, deleteTestApp, gotoTestApp, waitForPuterReady, FIXTURE_URL } from '../helpers/testApp.js'; + +const ATTACH_FIXTURE_URL = FIXTURE_URL.replace( + 'menubar-contextmenu.html', + 'attach-custom-domain.html', +); + +// The test instance runs without the custom domains backend, so the dialog +// shows its unavailable pane. Picking and adding domains needs that backend +// and is covered by the extension's own tests. + +test.describe('puter.hosting.attach (env=app)', () => { + test('the desktop hosts the dialog and closing it resolves null', async ({ page }) => { + const appName = await registerTestApp(page, { fixtureURL: ATTACH_FIXTURE_URL }); + try { + const appFrame = await gotoTestApp(page, appName); + + await appFrame.locator('#attach').click(); + const dialog = page.locator('.custom-domain-overlay'); + await expect(dialog).toBeVisible(); + // The site and the requesting app are named from the GUI's own + // records, not from anything the app can style. + await expect(dialog.locator('.custom-domain-intro')).toContainText('e2e-attach-site.'); + await expect(dialog.locator('.custom-domain-requester')).toContainText(appName); + await expect(dialog.locator('[data-pane="unavailable"]')).toBeVisible({ timeout: 15_000 }); + + await dialog.locator('.app-feedback-x').click(); + await expect(appFrame.locator('#log [data-entry="attach:null"]')).toBeVisible(); + await expect(dialog).toBeHidden(); + } finally { + await deleteTestApp(page, appName); + } + }); +}); + +test.describe('puter.hosting.attach (env=gui)', () => { + test('rejects on the Puter desktop itself', async ({ page }) => { + await page.goto('/'); + await waitForPuterReady(page); + const code = await page.evaluate(() => + window.puter.hosting.attach('e2e-attach-site').then(() => 'resolved', (e) => e?.code)); + expect(code).toBe('not_available'); + }); +}); + +test.describe('puter.hosting.attach (env=web popup)', () => { + test('popup hosts the dialog, resolves null on close, and never hands the opener a token', async ({ page }) => { + await page.goto('/'); + await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 }); + + await page.goto(ATTACH_FIXTURE_URL); + await page.locator('body.ready').waitFor({ timeout: 60_000 }); + + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.locator('#attach').click(), + ]); + + const dialog = popup.locator('.custom-domain-overlay'); + await expect(dialog).toBeVisible({ timeout: 60_000 }); + + await popup.close(); + await expect(page.locator('#log [data-entry="attach:null"]')).toBeVisible({ timeout: 15_000 }); + + // attach-custom-domain is a NON_AUTH popup action. + const authToken = await page.evaluate(() => window.puter.authToken ?? null); + expect(authToken).toBeNull(); + }); +});